Skip to content

Commit e5cce09

Browse files
test(ui): promote the Studio live specs into Live E2E UI, with a stored-state re-read each (#27770)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ Fixes #27554 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 的两组浏览器实时用例(对象设计器 F1–F4、页面编辑器)从「只在夜间跑」移进必过检查 `Live E2E UI` 的清单;两组各加一条「保存后经 `/meta` 回读存储内容」的断言。 - **为什么改**:Studio 保存路径近期多次在合并后才被发现坏掉(#27459、#27461、#26917),而这两组用例只在夜间跑、红了不拦合并;加回读断言后,测的是真正存下来的东西,而不只是界面状态。 - **风险与回滚**:只动测试和 `ui/package.json` 的两条脚本清单,不改产品代码。代价是 `Run the live e2e specs` 步骤本地从约 18 秒变成约 36 秒,仍远低于 #27550 定的 2 分钟预算。回滚 = 把两个文件名移回 `test:e2e:live:nightly`。 - **先看**:`ui/e2e/live/studio-object-designer.spec.ts` 的 F4(新建可写包、保存、回读草稿);`ui/e2e/live/studio-editor.spec.ts` 的 Add block 用例尾部;`ui/package.json` 两条脚本。 - **两分钟验证**:看本 PR 的 `Live E2E UI` 检查日志,`Run the live e2e specs` 步骤应列出 11 条全部通过,其中 6 条来自两个 studio 文件,步骤耗时在 2 分钟内。 - **需要你做的**:无,席位落地。 ## What changes - `ui/package.json`: `studio-object-designer.spec.ts` and `studio-editor.spec.ts` move from `test:e2e:live:nightly` to `test:e2e:live:ci`, the allowlist the required `Live E2E UI` job runs. Both passed the promotion rule (readings below), so neither stays in nightly and no header carries a "kept in nightly" reason. - `studio-object-designer.spec.ts` F4 now saves and reads back. The showcase package is provided by code, so a save into it is refused (`WRITABLE_PACKAGE_REQUIRED`, measured: `PUT /api/v1/meta/object/NAME?mode=draft&package=com.example.showcase` answers 422). F4 therefore authors its own package through `POST /api/v1/packages`, opens the designer on it, runs the same picklist + empty-row steps and the same two negative banner assertions as before, clicks Save, asserts the `PUT ...?mode=draft` answered 2xx, and reads `GET /api/v1/meta/object/NAME?state=draft`: the stored object has exactly one `select` field and its `options` equal `[{ value: 'open', label: 'Open' }]`, so the empty row was not stored. The package is deleted in `finally`, as F1 already does for its own. - `studio-editor.spec.ts` "Add block opens a block-type picker…" now picks Card after asserting the picker, waits for the editor's autosave `PUT /api/v1/meta/page/NAME?mode=draft` (asserted 2xx), and reads `GET /api/v1/meta/page/NAME?state=draft`: the first region holds one more block than the shipped copy, and the new last block is `page:card`. - Both headers say they are in the `:ci` allowlist since this card, under the rule, the way `saved-view-filter.spec.ts` says it. ## Why The card's task 1–4: measure, promote what passes the 3/3 + 5/5 rule, add one stored-state re-read per spec, stay inside #27550's 2-minute budget for the `Run the live e2e specs` step. Both specs fit, so the higher-yield-only fallback (F4 / F1) did not apply. ## Verification All runs: this branch's workspace backend (`ui/e2e/live/ci/start-backend.sh`, `objectstack dev --seed-admin --fresh` on `examples/app-showcase`, port 4010) and the Console production build (`VITE_BASE_PATH=/ vite build`) served by `vite preview` on 5190 with `DEV_PROXY_TARGET`, the `Live E2E UI` job's stack step for step; `--retries 0`; one long-lived backend across all runs (the CI job boots a fresh one per run, so this is the harder condition). Container: 4 shared cores. | Reading | Runs | Result | Playwright total per run | |---|---|---|---| | Baseline, both specs unchanged (task 1) | 10 | 10/10 green, 60/60 tests | 16.4–17.9 s | | Both specs as changed here | 10 | 10/10 green, 60/60 tests (incl. 3/3 consecutive) | 19.1–20.0 s | | Enlarged allowlist, `pnpm run test:e2e:live:ci` (task 2's 5/5) | 5 | 5/5 green, 55/55 tests | 33.9–37.2 s (wall 35.3–38.7 s) | | Incumbent four specs alone (control) | 3 | 3/3 green | 16.7–17.8 s (wall 18.0–19.3 s) | <details><summary>Per-test time, 10 runs each (mean / max)</summary> | Test | Before | After | |---|---|---| | designer F1 (package switcher) | 2.4 / 2.5 s | 2.4 / 2.5 s | | designer F2 (typed underscore) | 2.1 / 2.2 s | 2.2 / 2.3 s | | designer F3 (label to API name) | 2.3 / 3.3 s | 2.4 / 2.6 s | | designer F4 (picklist empty row; now saves + re-reads) | 3.6 / 4.0 s | 4.0 / 4.4 s | | editor, page editor loads | 2.8 / 3.0 s | 2.8 / 3.0 s | | editor, Add block picker (now adds Card + re-reads draft) | 2.8 / 3.1 s | 4.6 / 5.0 s | </details> Budget: the two specs add about 18–20 s to the step locally (incumbent four 18–19 s wall, enlarged six 35–39 s). The job comment in `ci.yml` measured the incumbent step at 14–24 s (median 21 s) on CI, so the projected step is about 40 s, against the 120 s budget. This PR's own `Live E2E UI` run is the first CI reading. Nightly readings: the nightly live tier that runs `test:e2e:live:nightly` (`examples/app-showcase/test/console-live-specs.live.test.ts`) has at most two scheduled runs since it was wired (2026-10-28 and 2026-10-29), and both of those `Nightly tiers` runs failed on other shards' packages; their per-spec logs were not readable from this container (the job-log download is refused by the proxy). The ≥ 10 readings are therefore the local ones above, which the card allows. No ablation: the diff changes tests and a script list only, no product behaviour. Gates at head `75f8060c`: `node scripts/pm/dispatch-gates.mjs --commands` derived 31 families from the three changed paths; each ran locally, plus `node scripts/check-sdui-manifest.mjs`: 32 lines, every one exit 0; `--ran` reconciliation: 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN. CI runs the rest (the repo-wide scans, `Live E2E UI` itself). <details><summary>Gate lines (command :: exit code)</summary> ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-osv-exemptions.mjs :: exit 0 node scripts/check-osv-exemptions.mjs --self-test :: exit 0 node scripts/osv-base-relative.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:manifest-repository-directory :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:override-consistency :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:turbo-task-graph :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-export-contract-resolve :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:workspace-manifest-cycles :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 ``` </details> ## Acceptance notes - `.github/workflows/ci.yml`'s `live-e2e-ui` comment still says "What it runs: the four allowlisted live specs"; it is now six. `ci.yml` is out of bounds for this card, so the wording is left for whoever next edits that job; the count is not read by any gate. - `examples/app-showcase/test/console-live-specs.live.test.ts` says "Four of its specs are the required `Live E2E UI` job". Same stale count, outside this card's file surface; the test reads the nightly script by name, so its behaviour is unchanged (13 specs instead of 15). - `docs/qa/platform-checklist/areas/studio-authoring.json` says F4 "never publishes or reads the stored options". F4 now saves a draft and reads its stored options (it still does not publish), so that note is narrower than the spec now is; the ledger is its owner's to re-read, outside this card. - Siblings #27551 and #27552 edit the same two script lines; whichever lands second takes a one-line merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaYrosoxy8gyWwRM83)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21c4bdd commit e5cce09

3 files changed

Lines changed: 89 additions & 26 deletions

File tree

‎ui/e2e/live/studio-editor.spec.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,33 @@ import { test, expect, type APIRequestContext } from '@playwright/test';
1717
* canvas, not in a "Layout" form section: the page form prunes `regions`
1818
* (`PAGE_FIELDS_OWNED_ELSEWHERE` in app-shell's `page-schema.ts`), so the
1919
* canvas's region controls are where the layout config renders.
20+
*
21+
* In the required `Live E2E UI` job's `test:e2e:live:ci` allowlist since
22+
* objectstack#27554, under the promotion rule (3/3 consecutive green, plus a
23+
* 5/5 run of the enlarged allowlist); the readings are on that card's PR.
2024
*/
2125
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
2226
const SHIPPED_PAGE = 'showcase_project_workspace';
2327

24-
/** Author an editable copy of the shipped page; returns its name and how many regions it lays out. */
25-
async function authorEditablePage(request: APIRequestContext): Promise<{ name: string; regions: number }> {
28+
/** 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. */
29+
async function authorEditablePage(
30+
request: APIRequestContext,
31+
): Promise<{ name: string; regions: number; firstRegionBlocks: number }> {
2632
const res = await request.get(`${API}/api/v1/meta/page/${SHIPPED_PAGE}`);
2733
expect(res.ok(), await res.text()).toBeTruthy();
2834
const shipped = (await res.json()).item as Record<string, unknown>;
2935
// Underscore keys are the server's provenance stamps, not page content.
3036
const body = Object.fromEntries(Object.entries(shipped).filter(([k]) => !k.startsWith('_')));
3137
const regions = Array.isArray(body.regions) ? body.regions.length : 0;
3238
expect(regions, 'the shipped page lays out regions to copy').toBeGreaterThan(0);
39+
const firstRegionBlocks = ((body.regions as { components?: unknown[] }[])[0].components ?? []).length;
3340

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

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

65-
test('Add block opens a block-type picker with configurable block kinds', async ({ page, request }) => {
66-
const { name } = await authorEditablePage(request);
72+
test('Add block opens a block-type picker with configurable block kinds, and the added block is stored', async ({ page, request }) => {
73+
const { name, firstRegionBlocks } = await authorEditablePage(request);
6774
try {
6875
await page.goto(`/apps/showcase_app/metadata/page/${name}`);
6976
await expect(page.getByTestId('metadata-edit-page')).toBeVisible();
@@ -76,6 +83,21 @@ test('Add block opens a block-type picker with configurable block kinds', async
7683
await expect(dialog.getByRole('button', { name: /Card/i })).toBeVisible();
7784
await expect(dialog.getByRole('button', { name: /Section/i })).toBeVisible();
7885
await expect(dialog.getByRole('button', { name: /Record details/i })).toBeVisible();
86+
87+
// The save round trip, not only the canvas: picking a kind autosaves a
88+
// draft, and the draft read back through /meta holds the new block at the
89+
// end of the first region.
90+
const saved = page.waitForResponse(
91+
(r) => r.request().method() === 'PUT' && r.url().includes(`/api/v1/meta/page/${name}?mode=draft`),
92+
);
93+
await dialog.getByRole('button', { name: /Card/i }).first().click();
94+
const save = await saved;
95+
expect(save.ok(), await save.text()).toBeTruthy();
96+
const stored = await request.get(`${API}/api/v1/meta/page/${name}?state=draft`);
97+
expect(stored.ok(), await stored.text()).toBeTruthy();
98+
const blocks = (await stored.json()).item.regions[0].components as { type?: string }[];
99+
expect(blocks).toHaveLength(firstRegionBlocks + 1);
100+
expect(blocks[firstRegionBlocks].type).toBe('page:card');
79101
} finally {
80102
await removePage(request, name);
81103
}

‎ui/e2e/live/studio-object-designer.spec.ts‎

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect, type Page } from '@playwright/test';
1+
import { test, expect, type APIRequestContext, type Page } from '@playwright/test';
22

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

1822
// The object designer is reached through the showcase's own app: a stock
@@ -21,6 +25,20 @@ import { test, expect, type Page } from '@playwright/test';
2125
// resolve under any served app.
2226
const PKG = 'com.example.showcase';
2327
const NEW_OBJ = `/apps/showcase_app/metadata/object/new?package=${PKG}`;
28+
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
29+
30+
/**
31+
* A package the environment authors, for a test that SAVES: the showcase is
32+
* provided by code, so a save into it is refused (`WRITABLE_PACKAGE_REQUIRED`).
33+
*/
34+
async function authorWritablePackage(request: APIRequestContext, stamp: number): Promise<string> {
35+
const id = `com.e2e.designer${stamp}`;
36+
const res = await request.post(`${API}/api/v1/packages`, {
37+
data: { manifest: { id, name: `E2E Designer ${stamp}`, version: '0.1.0', type: 'app' } },
38+
});
39+
expect(res.ok(), await res.text()).toBeTruthy();
40+
return id;
41+
}
2442

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

55-
test('F4: adding a picklist + an empty option row does not trip spec validation', async ({ page }) => {
56-
await page.goto(NEW_OBJ);
57-
await page.getByTestId('object-name-input').fill('asset');
58-
await addField(page, 'Picklist');
59-
// Give the picklist its option source first: a select with no options is a
60-
// real spec error (it needs `options` or `picklist`), and its banner is not
61-
// the one this test is about.
62-
await page.getByPlaceholder('Label', { exact: true }).first().fill('Open');
63-
await page.getByPlaceholder('value', { exact: true }).first().fill('open');
64-
await page.getByRole('button', { name: /Add value/i }).click();
65-
// Live spec validation is debounced, so settle before a NEGATIVE assertion:
66-
// the empty option row must NOT surface the developer-oriented
67-
// "System identifier must be at least 2 characters" spec error. (With the
68-
// bug the row persists to def.options and this banner appears.)
69-
await page.waitForTimeout(1200);
70-
await expect(page.getByTestId('metadata-validation-banner')).toHaveCount(0);
71-
await expect(page.getByText(/System identifier must be/i)).toHaveCount(0);
73+
test('F4: adding a picklist + an empty option row does not trip spec validation, and is not stored', async ({ page, request }) => {
74+
const stamp = Date.now();
75+
const pkgId = await authorWritablePackage(request, stamp);
76+
const objName = `e2e_asset${stamp}`;
77+
try {
78+
await page.goto(`/apps/showcase_app/metadata/object/new?package=${pkgId}`);
79+
await page.getByTestId('object-name-input').fill(objName);
80+
await addField(page, 'Picklist');
81+
// Give the picklist its option source first: a select with no options is a
82+
// real spec error (it needs `options` or `picklist`), and its banner is not
83+
// the one this test is about.
84+
await page.getByPlaceholder('Label', { exact: true }).first().fill('Open');
85+
await page.getByPlaceholder('value', { exact: true }).first().fill('open');
86+
await page.getByRole('button', { name: /Add value/i }).click();
87+
// Live spec validation is debounced, so settle before a NEGATIVE assertion:
88+
// the empty option row must NOT surface the developer-oriented
89+
// "System identifier must be at least 2 characters" spec error. (With the
90+
// bug the row persists to def.options and this banner appears.)
91+
await page.waitForTimeout(1200);
92+
await expect(page.getByTestId('metadata-validation-banner')).toHaveCount(0);
93+
await expect(page.getByText(/System identifier must be/i)).toHaveCount(0);
94+
95+
// The save round trip, not only the UI state: Save stores a draft, and the
96+
// draft read back through /meta carries the one authored option and not
97+
// the empty row.
98+
const saved = page.waitForResponse(
99+
(r) => r.request().method() === 'PUT' && r.url().includes(`/api/v1/meta/object/${objName}`),
100+
);
101+
await page.getByRole('button', { name: /^Save/ }).click();
102+
const save = await saved;
103+
expect(save.ok(), await save.text()).toBeTruthy();
104+
const stored = await request.get(`${API}/api/v1/meta/object/${objName}?state=draft`);
105+
expect(stored.ok(), await stored.text()).toBeTruthy();
106+
const fields = Object.values((await stored.json()).item.fields as Record<string, { type?: string; options?: unknown }>);
107+
const picklists = fields.filter((f) => f.type === 'select');
108+
expect(picklists).toHaveLength(1);
109+
expect(picklists[0].options).toEqual([{ value: 'open', label: 'Open' }]);
110+
} finally {
111+
await request.delete(`${API}/api/v1/packages/${pkgId}`);
112+
}
72113
});
73114

74115
test('F1: a newly created package appears in the switcher without a reload', async ({ page }) => {

‎ui/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@
111111
"test:e2e": "playwright test",
112112
"test:e2e:ui": "playwright test --ui",
113113
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
114-
"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",
115-
"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",
114+
"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",
115+
"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",
116116
"test:e2e:import-harness": "playwright test --config=playwright.import-harness.config.ts",
117117
"test:e2e:import-console": "playwright test --config=playwright.import-console.config.ts"
118118
},

0 commit comments

Comments
 (0)