Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core-web/apps/dotcms-ui-e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Unsure? **Codegen first.** Avoid fragile `#dijit_*` IDs, CSS when `data-testid`
- **Nav:** `Portlet` from `@utils/portlets`; new content via `NewEditContentFormPage.goToNew()` (listing → New), not direct `/content/new/` URL.
- **Dojo menus:** wait for menu visibility, then click normally (no `force: true`).

Field-specific selectors and flows: copy `tests/.../helpers/` and nearest `*.spec.ts` (e.g. `relationship-field/`).
Field-specific selectors and flows: copy `tests/.../helpers/` and nearest `*.spec.ts` (e.g. `relationship-field/`, `key-value-field/`).

## Where to put code

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect, type Locator, type Page } from '@playwright/test';

/**
* Locator wrapper for the Key/Value field (`dot-edit-content-key-value`).
* Scopes interactions to `data-testid="field-{variable}"`.
*/
export class KeyValueField {
readonly root: Locator;
readonly keyInput: Locator;
readonly valueInput: Locator;
readonly saveButton: Locator;
readonly keyCells: Locator;
readonly rows: Locator;

constructor(
private page: Page,
readonly fieldVariable = 'keyValueField'
) {
this.root = page.getByTestId(`field-${fieldVariable}`);
this.keyInput = this.root.getByTestId('key-input');
this.valueInput = this.root.getByTestId('value-input');
this.saveButton = this.root.getByTestId('save-button');
this.keyCells = this.root.getByTestId('dot-key-value-key');
this.rows = this.root.locator('tr.dot-key-value-table-row');
}

async expectVisible(): Promise<void> {
await expect(this.keyInput).toBeVisible({ timeout: 15000 });
await expect(this.valueInput).toBeVisible();
await expect(this.saveButton).toBeVisible();
}

async addEntry(key: string, value: string): Promise<void> {
await this.keyInput.fill(key);
await this.valueInput.fill(value);
await this.saveButton.click();
await expect(this.exactKeyCell(key)).toHaveCount(1, { timeout: 10000 });
}

async expectEntryCount(count: number): Promise<void> {
await expect(this.keyCells).toHaveCount(count, { timeout: 10000 });
}

private static escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

private exactKeyCell(key: string): Locator {
return this.keyCells.filter({
hasText: new RegExp(`^${KeyValueField.escapeRegExp(key)}$`)
});
}

private valueInputForKey(key: string): Locator {
return this.exactKeyCell(key)
.locator('xpath=ancestor::tr[contains(@class,"dot-key-value-table-row")][1]')
.getByTestId('dot-key-value-input');
}

async expectEntry(key: string, value?: string): Promise<void> {
const keyCell = this.exactKeyCell(key);
await expect(keyCell).toHaveCount(1, { timeout: 10000 });
await expect(keyCell).toHaveText(key);

if (value !== undefined) {
await expect
.poll(async () => this.valueInputForKey(key).inputValue(), { timeout: 10000 })
.toBe(value);
}
}

async expectKeyAbsent(key: string): Promise<void> {
await expect(this.exactKeyCell(key)).toHaveCount(0);
}

async editEntryValue(key: string, newValue: string): Promise<void> {
const valueInput = this.valueInputForKey(key);
await expect(valueInput).toBeVisible({ timeout: 10000 });
await valueInput.fill(newValue);
await valueInput.press('Enter');
await expect.poll(async () => valueInput.inputValue(), { timeout: 10000 }).toBe(newValue);
}

async deleteEntryByKey(key: string): Promise<void> {
await this.exactKeyCell(key)
.locator('xpath=ancestor::tr[contains(@class,"dot-key-value-table-row")][1]')
.getByTestId('dot-key-value-delete-button')
.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { faker } from '@faker-js/faker';
import { NewEditContentFormPage } from '@pages';
import { expect, test } from '@playwright/test';
import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType';
import {
createFakePayloadCustomField,
createFakePayloadKeyValueField,
createFakePayloadTextField
} from '@utils/dot-content-types.mock';
import { uniqueSuffix } from '@utils/utils';

import { KeyValueField } from './helpers/key-value-field';

const KEY_VALUE_FIELD_VARIABLE = 'keyValueField';
const CUSTOM_FIELD_VARIABLE = 'customField';

async function saveAndReload(page: import('@playwright/test').Page, title: string) {
const formPage = new NewEditContentFormPage(page);
await formPage.fillTextField(title);
await formPage.save();

await page.waitForURL(/\/content\/([a-f0-9-]+)/);
const [, savedContentIdentifier] = page
.url()
.match(/\/content\/([a-f0-9-]+)/) as RegExpMatchArray;
expect(savedContentIdentifier).toBeTruthy();

await page.goto(`/dotAdmin/#/content/${savedContentIdentifier}`);
await page.waitForLoadState('domcontentloaded');
await page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 });

return savedContentIdentifier;
}

test.describe('Key Value Field', () => {
test.describe('Isolated content type', () => {
let contentType: ContentType | null = null;
let contentTypeVariable: string;

test.beforeEach(async ({ request }) => {
contentType = await createFakeContentType(request, {
name: `E2EKeyValueField${uniqueSuffix()}`,
fields: [
createFakePayloadTextField({
name: 'Title',
variable: 'title',
sortOrder: 1
}),
createFakePayloadKeyValueField({
name: 'Key Value Field',
variable: KEY_VALUE_FIELD_VARIABLE,
sortOrder: 2
})
]
});
contentTypeVariable = contentType.variable;
});

test.afterEach(async ({ request }) => {
if (contentType) {
await deleteContentType(request, contentType.id);
contentType = null;
}
});

test('first key-value entry is not split into characters @critical', async ({ page }) => {
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.expectVisible();
await field.addEntry('mykey', 'myvalue');

await field.expectEntryCount(1);
await field.expectEntry('mykey', 'myvalue');
await field.expectKeyAbsent('m');
await field.expectKeyAbsent('y');
await field.expectKeyAbsent('k');
await field.expectKeyAbsent('e');
});

test('save reloads and persists first key-value entry @critical', async ({ page }) => {
const title = `E2E KV ${faker.lorem.word()}`;
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.expectVisible();
await field.addEntry('mykey', 'myvalue');

await saveAndReload(page, title);

const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await reloadedField.expectVisible();
await reloadedField.expectEntryCount(1);
await reloadedField.expectEntry('mykey', 'myvalue');
});

test('save reloads and persists multiple key-value entries @critical', async ({ page }) => {
const title = `E2E KV Multi ${faker.lorem.word()}`;
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.expectVisible();
await field.addEntry('firstKey', 'firstValue');
await field.addEntry('secondKey', 'secondValue');

await field.expectEntryCount(2);
await field.expectEntry('firstKey', 'firstValue');
await field.expectEntry('secondKey', 'secondValue');

await saveAndReload(page, title);

const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await reloadedField.expectEntryCount(2);
await reloadedField.expectEntry('firstKey', 'firstValue');
await reloadedField.expectEntry('secondKey', 'secondValue');
});

test('edit key-value entry value persists after save and reload @smoke', async ({
page
}) => {
const title = `E2E KV Edit ${faker.lorem.word()}`;
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.addEntry('editKey', 'originalValue');
await field.editEntryValue('editKey', 'updatedValue');
await field.expectEntry('editKey', 'updatedValue');

await saveAndReload(page, title);

const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await reloadedField.expectEntry('editKey', 'updatedValue');
});

test('delete key-value entry persists after save and reload @smoke', async ({ page }) => {
const title = `E2E KV Delete ${faker.lorem.word()}`;
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.addEntry('keepKey', 'keepValue');
await field.addEntry('removeKey', 'removeValue');
Comment thread
nicobytes marked this conversation as resolved.
Comment thread
nicobytes marked this conversation as resolved.
await field.expectEntryCount(2);

await field.deleteEntryByKey('removeKey');
await field.expectEntryCount(1);
await field.expectEntry('keepKey', 'keepValue');
await field.expectKeyAbsent('removeKey');

await saveAndReload(page, title);

const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await reloadedField.expectEntryCount(1);
await reloadedField.expectEntry('keepKey', 'keepValue');
await reloadedField.expectKeyAbsent('removeKey');
});
});

test.describe('Regression #36318 with custom field', () => {
let contentType: ContentType | null = null;
let contentTypeVariable: string;

test.beforeEach(async ({ request }) => {
contentType = await createFakeContentType(request, {
name: `E2EKeyValueCustom${uniqueSuffix()}`,
fields: [
createFakePayloadTextField({
name: 'Title',
variable: 'title',
sortOrder: 1
}),
createFakePayloadCustomField({
name: 'Custom Field',
variable: CUSTOM_FIELD_VARIABLE,
sortOrder: 2
}),
createFakePayloadKeyValueField({
name: 'Key Value Field',
variable: KEY_VALUE_FIELD_VARIABLE,
sortOrder: 3
})
]
});
contentTypeVariable = contentType.variable;
});

test.afterEach(async ({ request }) => {
if (contentType) {
await deleteContentType(request, contentType.id);
contentType = null;
}
});

test('first key-value entry is not split when custom field is present @critical', async ({
page
}) => {
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

await expect(page.getByTestId(`field-${CUSTOM_FIELD_VARIABLE}`)).toBeVisible({
timeout: 15000
});

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.expectVisible();
await field.addEntry('mykey', 'myvalue');

await field.expectEntryCount(1);
await field.expectEntry('mykey', 'myvalue');
await field.expectKeyAbsent('m');
await field.expectKeyAbsent('y');
await field.expectKeyAbsent('k');
await field.expectKeyAbsent('e');
});

test('save reloads and persists key-value entry with custom field present @critical', async ({
page
}) => {
const title = `E2E KV Custom ${faker.lorem.word()}`;
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

await expect(page.getByTestId(`field-${CUSTOM_FIELD_VARIABLE}`)).toBeVisible({
timeout: 15000
});

const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await field.addEntry('mykey', 'myvalue');

await saveAndReload(page, title);

const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE);
await reloadedField.expectEntryCount(1);
await reloadedField.expectEntry('mykey', 'myvalue');
});
});
});
Loading
Loading