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
32 changes: 27 additions & 5 deletions ui/e2e/live/studio-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,33 @@ import { test, expect, type APIRequestContext } from '@playwright/test';
* canvas, not in a "Layout" form section: the page form prunes `regions`
* (`PAGE_FIELDS_OWNED_ELSEWHERE` in app-shell's `page-schema.ts`), so the
* canvas's region controls are where the layout config renders.
*
* In the required `Live E2E UI` job's `test:e2e:live:ci` allowlist since
* objectstack#27554, under the promotion rule (3/3 consecutive green, plus a
* 5/5 run of the enlarged allowlist); the readings are on that card's PR.
*/
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
const SHIPPED_PAGE = 'showcase_project_workspace';

/** Author an editable copy of the shipped page; returns its name and how many regions it lays out. */
async function authorEditablePage(request: APIRequestContext): Promise<{ name: string; regions: number }> {
/** Author an editable copy of the shipped page; returns its name, how many regions it lays out and how many blocks its first region holds. */
async function authorEditablePage(
request: APIRequestContext,
): Promise<{ name: string; regions: number; firstRegionBlocks: number }> {
const res = await request.get(`${API}/api/v1/meta/page/${SHIPPED_PAGE}`);
expect(res.ok(), await res.text()).toBeTruthy();
const shipped = (await res.json()).item as Record<string, unknown>;
// Underscore keys are the server's provenance stamps, not page content.
const body = Object.fromEntries(Object.entries(shipped).filter(([k]) => !k.startsWith('_')));
const regions = Array.isArray(body.regions) ? body.regions.length : 0;
expect(regions, 'the shipped page lays out regions to copy').toBeGreaterThan(0);
const firstRegionBlocks = ((body.regions as { components?: unknown[] }[])[0].components ?? []).length;

const name = `e2e_studio_editor_${Date.now()}`;
const put = await request.put(`${API}/api/v1/meta/page/${name}`, {
data: { ...body, name, label: `E2E Studio Editor ${name.slice(-6)}` },
});
expect(put.ok(), await put.text()).toBeTruthy();
return { name, regions };
return { name, regions, firstRegionBlocks };
}

async function removePage(request: APIRequestContext, name: string) {
Expand All @@ -62,8 +69,8 @@ test('the page editor loads with its config sections', async ({ page, request })
}
});

test('Add block opens a block-type picker with configurable block kinds', async ({ page, request }) => {
const { name } = await authorEditablePage(request);
test('Add block opens a block-type picker with configurable block kinds, and the added block is stored', async ({ page, request }) => {
const { name, firstRegionBlocks } = await authorEditablePage(request);
try {
await page.goto(`/apps/showcase_app/metadata/page/${name}`);
await expect(page.getByTestId('metadata-edit-page')).toBeVisible();
Expand All @@ -76,6 +83,21 @@ test('Add block opens a block-type picker with configurable block kinds', async
await expect(dialog.getByRole('button', { name: /Card/i })).toBeVisible();
await expect(dialog.getByRole('button', { name: /Section/i })).toBeVisible();
await expect(dialog.getByRole('button', { name: /Record details/i })).toBeVisible();

// The save round trip, not only the canvas: picking a kind autosaves a
// draft, and the draft read back through /meta holds the new block at the
// end of the first region.
const saved = page.waitForResponse(
(r) => r.request().method() === 'PUT' && r.url().includes(`/api/v1/meta/page/${name}?mode=draft`),
);
await dialog.getByRole('button', { name: /Card/i }).first().click();
const save = await saved;
expect(save.ok(), await save.text()).toBeTruthy();
const stored = await request.get(`${API}/api/v1/meta/page/${name}?state=draft`);
expect(stored.ok(), await stored.text()).toBeTruthy();
const blocks = (await stored.json()).item.regions[0].components as { type?: string }[];
expect(blocks).toHaveLength(firstRegionBlocks + 1);
expect(blocks[firstRegionBlocks].type).toBe('page:card');
} finally {
await removePage(request, name);
}
Expand Down
79 changes: 60 additions & 19 deletions ui/e2e/live/studio-object-designer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type APIRequestContext, type Page } from '@playwright/test';

/**
* Live e2e for the Studio OBJECT designer — a "dogfood" gate that drives the
Expand All @@ -12,7 +12,11 @@ import { test, expect, type Page } from '@playwright/test';
* F2 — per-keystroke identifier sanitisation (must TYPE, not fill)
* F1 — cross-component reactivity (create package → switcher refresh)
* F3 — label→api-name derivation on blur
* F4 — empty picklist row must not trip spec validation
* F4 — empty picklist row must not trip spec validation, and is not stored
*
* In the required `Live E2E UI` job's `test:e2e:live:ci` allowlist since
* objectstack#27554, under the promotion rule (3/3 consecutive green, plus a
* 5/5 run of the enlarged allowlist); the readings are on that card's PR.
*/

// The object designer is reached through the showcase's own app: a stock
Expand All @@ -21,6 +25,20 @@ import { test, expect, type Page } from '@playwright/test';
// resolve under any served app.
const PKG = 'com.example.showcase';
const NEW_OBJ = `/apps/showcase_app/metadata/object/new?package=${PKG}`;
const API = process.env.LIVE_API_URL || 'http://localhost:3000';

/**
* A package the environment authors, for a test that SAVES: the showcase is
* provided by code, so a save into it is refused (`WRITABLE_PACKAGE_REQUIRED`).
*/
async function authorWritablePackage(request: APIRequestContext, stamp: number): Promise<string> {
const id = `com.e2e.designer${stamp}`;
const res = await request.post(`${API}/api/v1/packages`, {
data: { manifest: { id, name: `E2E Designer ${stamp}`, version: '0.1.0', type: 'app' } },
});
expect(res.ok(), await res.text()).toBeTruthy();
return id;
}

/** Add a field of the given palette type into the open object designer. */
async function addField(page: Page, typeName: string) {
Expand Down Expand Up @@ -52,23 +70,46 @@ test('F3: a field API name derives from its label on blur', async ({ page }) =>
await expect(page.getByTestId('field-apiname-input')).toHaveValue('asset_code');
});

test('F4: adding a picklist + an empty option row does not trip spec validation', async ({ page }) => {
await page.goto(NEW_OBJ);
await page.getByTestId('object-name-input').fill('asset');
await addField(page, 'Picklist');
// Give the picklist its option source first: a select with no options is a
// real spec error (it needs `options` or `picklist`), and its banner is not
// the one this test is about.
await page.getByPlaceholder('Label', { exact: true }).first().fill('Open');
await page.getByPlaceholder('value', { exact: true }).first().fill('open');
await page.getByRole('button', { name: /Add value/i }).click();
// Live spec validation is debounced, so settle before a NEGATIVE assertion:
// the empty option row must NOT surface the developer-oriented
// "System identifier must be at least 2 characters" spec error. (With the
// bug the row persists to def.options and this banner appears.)
await page.waitForTimeout(1200);
await expect(page.getByTestId('metadata-validation-banner')).toHaveCount(0);
await expect(page.getByText(/System identifier must be/i)).toHaveCount(0);
test('F4: adding a picklist + an empty option row does not trip spec validation, and is not stored', async ({ page, request }) => {
const stamp = Date.now();
const pkgId = await authorWritablePackage(request, stamp);
const objName = `e2e_asset${stamp}`;
try {
await page.goto(`/apps/showcase_app/metadata/object/new?package=${pkgId}`);
await page.getByTestId('object-name-input').fill(objName);
await addField(page, 'Picklist');
// Give the picklist its option source first: a select with no options is a
// real spec error (it needs `options` or `picklist`), and its banner is not
// the one this test is about.
await page.getByPlaceholder('Label', { exact: true }).first().fill('Open');
await page.getByPlaceholder('value', { exact: true }).first().fill('open');
await page.getByRole('button', { name: /Add value/i }).click();
// Live spec validation is debounced, so settle before a NEGATIVE assertion:
// the empty option row must NOT surface the developer-oriented
// "System identifier must be at least 2 characters" spec error. (With the
// bug the row persists to def.options and this banner appears.)
await page.waitForTimeout(1200);
await expect(page.getByTestId('metadata-validation-banner')).toHaveCount(0);
await expect(page.getByText(/System identifier must be/i)).toHaveCount(0);

// The save round trip, not only the UI state: Save stores a draft, and the
// draft read back through /meta carries the one authored option and not
// the empty row.
const saved = page.waitForResponse(
(r) => r.request().method() === 'PUT' && r.url().includes(`/api/v1/meta/object/${objName}`),
);
await page.getByRole('button', { name: /^Save/ }).click();
const save = await saved;
expect(save.ok(), await save.text()).toBeTruthy();
const stored = await request.get(`${API}/api/v1/meta/object/${objName}?state=draft`);
expect(stored.ok(), await stored.text()).toBeTruthy();
const fields = Object.values((await stored.json()).item.fields as Record<string, { type?: string; options?: unknown }>);
const picklists = fields.filter((f) => f.type === 'select');
expect(picklists).toHaveLength(1);
expect(picklists[0].options).toEqual([{ value: 'open', label: 'Open' }]);
} finally {
await request.delete(`${API}/api/v1/packages/${pkgId}`);
}
});

test('F1: a newly created package appears in the switcher without a reload', async ({ page }) => {
Expand Down
4 changes: 2 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
"test:e2e:live:ci": "playwright test --config=playwright.live.config.ts screen-flow.spec.ts action-modal.spec.ts master-detail.spec.ts saved-view-filter.spec.ts",
"test:e2e:live:nightly": "playwright test --config=playwright.live.config.ts cascading-options.spec.ts declared-sharing-rules-reach-13091.spec.ts field-conditional-rules.spec.ts form-view-subforms.spec.ts grid-conditional-rules.spec.ts grid-file-upload.spec.ts grid-parent-rules.spec.ts inline-edit-polish-2572.spec.ts list-row-action-cel.spec.ts record-history-display.spec.ts required-when-submit.spec.ts studio-editor.spec.ts studio-object-designer.spec.ts summary-rollup.spec.ts user-filters.spec.ts",
"test:e2e:live:ci": "playwright test --config=playwright.live.config.ts screen-flow.spec.ts action-modal.spec.ts master-detail.spec.ts saved-view-filter.spec.ts studio-object-designer.spec.ts studio-editor.spec.ts",
"test:e2e:live:nightly": "playwright test --config=playwright.live.config.ts cascading-options.spec.ts declared-sharing-rules-reach-13091.spec.ts field-conditional-rules.spec.ts form-view-subforms.spec.ts grid-conditional-rules.spec.ts grid-file-upload.spec.ts grid-parent-rules.spec.ts inline-edit-polish-2572.spec.ts list-row-action-cel.spec.ts record-history-display.spec.ts required-when-submit.spec.ts summary-rollup.spec.ts user-filters.spec.ts",
"test:e2e:import-harness": "playwright test --config=playwright.import-harness.config.ts",
"test:e2e:import-console": "playwright test --config=playwright.import-console.config.ts"
},
Expand Down
Loading