Skip to content

Commit 2cbe559

Browse files
test(ui): live master-detail spec asserts the server stored the parent and child, not only that the batch was sent (#27759)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ Fixes #27551 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`Live E2E UI` 必跑的主从表单用例,原来只检查「点了创建后发出了 batch 请求」;现在还检查服务端回 2xx,并通过数据 API 把保存下来的项目(名称、客户、状态)和任务(指向该项目)读回来比对。 - 为什么改:实测 main 上第二个用例一直是「假绿」:它选了状态 `active`,而项目状态机只允许以 `planned` 创建,服务端回 400、什么都没存,用例照样通过。 - 风险与回滚:只改一个 e2e 文件,不碰产品代码、不改服务端鉴权;该步骤耗时实测反而从 29.0 秒降到约 25 秒。回滚 = revert 本 PR。 - 先看:`ui/e2e/live/master-detail.spec.ts` 的 `batchResults` / `readBack` 两个辅助函数,以及第二个用例改选 `planned` 的那一行。 - 两分钟验证:本 PR 的 `Live E2E UI` 检查为绿,日志里两个用例名为 "Create stores …" / "Create with a task line stores …"。 - 需要你做的:无,席位落地。 ## What changes `ui/e2e/live/master-detail.spec.ts` only. Both tests now: 1. wait for the `POST /api/v1/batch` **response** the click on Create provokes (replaces the discarded `waitForRequest` plus the fixed `waitForTimeout(500)`); 2. keep the request-shape assertions on `response.request().postDataJSON()` (they name WHICH half broke: the form or the server); 3. assert the batch answered 2xx with a `results[]` body; the failure message carries the status and the server's body; 4. read the stored rows back through `GET /api/v1/data/OBJECT/ID` with the `request` fixture (the storageState's better-auth session cookie): parent `name`, `account` (equal to the id the form sent), `status`; child `title` and `project` equal to the parent id. The second test now picks status `planned` instead of `active`. `showcase_project`'s `project_status_flow` declares `initialStates: ['planned']` with `insert` in its events, so `active` on create is refused. The header now says what the spec proves; the stale "bearer token cannot write /batch" premise is gone. `ui/e2e/live/global-setup.ts` is unchanged: the harness session already writes. ## Tests All runs on a local stack built the way the `Live E2E UI` job builds it (same turbo filters, `vite build` with `VITE_BASE_PATH=/`, `start-backend.sh`, `vite preview` proxying to the backend), tree `481ade61` (main) and `f2e8b8ee` (this branch). <details><summary>Re-measure of the card's premise (Task 1)</summary> - On main, after one `test:e2e:live:ci` run, the data API listed the first test's project (`E2E Project …`, account set, status `planned`): the page's batch write succeeds under the harness session. The header's premise was stale. - The same run stored NO `E2E MD …` project and NO `E2E Task A` task, while both tests read green. - With the new assertions and the old `active` pick, test 2 fails: `POST /api/v1/batch answered 400: {"error":"Projects start as Planned, and then move only along the declared status flow.","code":"VALIDATION_FAILED","fields":[{"field":"status","code":"invalid_initial_state",…,"constraint":{"allowed":"planned"},"value":"active"}]}`. This is the red leg: the new assertions catch a refused batch that the old spec read green. </details> <details><summary>Step time, before and after (#27550 budget: the spec step under 2 minutes)</summary> `pnpm --dir ui run test:e2e:live:ci --retries 0` (the CI step's command), local 4-core shared box: - before (main `481ade61`): 5 passed (29.0s); master-detail 5.1s + 5.7s - after (`f2e8b8ee`), three consecutive runs: 5 passed (25.1s) · (25.7s) · (24.6s); master-detail 4.4s + 5.2s · 4.4s + 5.6s · 4.3s + 4.4s Faster because the fixed 500 ms sleep after the click is gone; the response wait replaces it. </details> <details><summary>Gates</summary> Head `f2e8b8ee`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 17 commands; each run and its exit code recorded before any pipe; `--ran` reconciliation: `17 derived, 17 run, 0 NOT-MEASURED, 0 UNRUN`. `check:query-options-erasure` first hit a 280 s local timeout (exit 124) and was re-run to completion (exit 0, 363 s); `check:new-line-citations` is a `ui/` script and first ran from the root (exit 254, script not found), then from `ui/` (exit 0, `0 new citation(s)`). Final readings: ```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 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:tier-file-adoption :: 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:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 pnpm --dir ui check:new-line-citations :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm --dir ui run type-check:e2e :: exit 0 ``` Everything CI runs beyond this subset: the PR's check-runs. </details> ## Acceptance notes - `ui/tsconfig.e2e.json`'s header cites this file for the `waitForRequest` predicate that was always true. That predicate was already deleted before this PR; this PR also removes the comment that explained the deletion, so the tsconfig sentence now points at a spec that no longer shows it. Past-tense history, outside this card's file surface; left as is. - The master-detail create form offers every `status` option, while the state machine admits only `planned` on create. A user who picks another value gets a 400 on Create. The showcase's own wizard page documents the same gap and leaves the general fix (a create form deriving allowed values from the object's state machine) to the Console. Reported to the seat, not fixed here. ## Verification The `Live E2E UI` check on this PR runs the changed spec against HEAD's backend. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6Cb84KtqCdPhzVBAUzB --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Cb84KtqCdPhzVBAUzB)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dc69d7 commit 2cbe559

1 file changed

Lines changed: 79 additions & 53 deletions

File tree

‎ui/e2e/live/master-detail.spec.ts‎

Lines changed: 79 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,95 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect, type APIRequestContext, type Page, type Response } from '@playwright/test';
22
import { selectOption, fillLookup } from './helpers';
33

44
/**
55
* Live e2e for the master-detail entry form (showcase "New Project + Tasks").
66
*
7-
* Regression guard for the "click Create, nothing happens" bug: the submit must
8-
* carry the user's input through to the atomic batch. The earlier symptom was a
9-
* `form.reset()` firing on a parent re-render (between the click and the
10-
* deferred requestSubmit) that wiped the form, so RHF then validated blank
11-
* required fields and never submitted. We assert the POST /api/v1/batch fires
12-
* with the populated parent payload — which only happens when the form kept its
13-
* values through submit.
7+
* What this spec proves, per test, against the real backend:
8+
* 1. the click on Create sends ONE `POST /api/v1/batch` whose operations
9+
* carry what the user typed (request shape: names WHICH half broke when a
10+
* save fails — the form, or the server);
11+
* 2. the server answers that batch 2xx;
12+
* 3. the data API then reads back what the batch stored: the parent with the
13+
* typed name, the chosen lookup and status, and (second test) the child
14+
* task pointing at that parent's id.
1415
*
15-
* (Asserting the persisted record needs a browser write-session; the live
16-
* harness injects a bearer token that the data API accepts for reads but not
17-
* the transactional /batch write, so we assert the outgoing request instead.)
16+
* The regression it was written for is "click Create, nothing happens": a
17+
* `form.reset()` firing on a parent re-render between the click and the
18+
* deferred requestSubmit wiped the form, so RHF validated blank required
19+
* fields and never submitted. Step 1 guards that; steps 2 and 3 guard the
20+
* server half, which a request-only assertion reads green when the batch is
21+
* refused, stores only the parent, or drops the child's parent reference.
22+
*
23+
* Auth: the page writes with the bearer token global-setup puts in the app
24+
* origin's localStorage; the read-back goes through the `request` fixture,
25+
* which carries the better-auth session cookie of the same storageState.
1826
*/
1927
const PAGE = '/apps/showcase_app/page/showcase_project_workspace';
28+
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
29+
30+
/** Click Create and resolve with the batch response it provoked. */
31+
async function submitBatch(page: Page): Promise<Response> {
32+
const [response] = await Promise.all([
33+
page.waitForResponse(
34+
(r) => r.request().method() === 'POST' && r.url().includes('/api/v1/batch'),
35+
{ timeout: 15_000 },
36+
),
37+
page.getByTestId('md-form-submit').click(),
38+
]);
39+
return response;
40+
}
41+
42+
/** Assert the batch answered 2xx and return its per-operation results. */
43+
async function batchResults(response: Response): Promise<any[]> {
44+
const body = await response.json().catch(() => null);
45+
expect(response.ok(), `POST /api/v1/batch answered ${response.status()}: ${JSON.stringify(body)}`).toBe(true);
46+
expect(Array.isArray(body?.results), `batch body carries results[]: ${JSON.stringify(body)}`).toBe(true);
47+
return body.results;
48+
}
2049

21-
async function captureBatch(page: import('@playwright/test').Page) {
22-
const reqs: any[] = [];
23-
page.on('request', (r) => {
24-
if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) {
25-
try { reqs.push(r.postDataJSON()); } catch { reqs.push(null); }
26-
}
27-
});
28-
return reqs;
50+
/** Read one stored record back through the data API. */
51+
async function readBack(request: APIRequestContext, object: string, id: string) {
52+
const res = await request.get(`${API}/api/v1/data/${object}/${encodeURIComponent(id)}`);
53+
expect(res.ok(), `GET ${object}/${id} answered ${res.status()}`).toBe(true);
54+
return (await res.json()).record;
2955
}
3056

3157
test.beforeEach(async ({ page }) => {
3258
await page.goto(PAGE);
3359
await expect(page.getByRole('heading', { name: 'New Project + Tasks' })).toBeVisible();
3460
});
3561

36-
test('Create submits the populated parent in one atomic batch', async ({ page }) => {
37-
const batches = await captureBatch(page);
62+
test('Create stores the populated parent through one atomic batch', async ({ page, request }) => {
3863
const name = `E2E Project ${Date.now()}`;
3964
await page.locator('input[name="name"]').fill(name);
4065
await fillLookup(page, 'account', 'North');
4166
await selectOption(page, 'status', 'planned');
4267
await expect(page.getByText('Northwind', { exact: false })).toBeVisible();
4368

44-
await Promise.all([
45-
// This predicate used to end `&& r.request?.().method?.() !== 'GET'`, which
46-
// objectui#4471's first-ever compile of this file reported as TS2339:
47-
// `Request` has no `request` member (that is `Response.request()`). Because
48-
// the whole chain is optional, it evaluated to `undefined !== 'GET'` — always
49-
// true — so the non-GET filter has never been in effect for a single run.
50-
// Deleted rather than repaired: removing a provably-always-true conjunct
51-
// cannot change what this wait resolves on, whereas restoring the intent as
52-
// `r.method() !== 'GET'` WOULD, and that needs the live stack to validate.
53-
// Nothing this test asserts depends on it either way — the `batches` array
54-
// below does its own `r.method() === 'POST'` filtering, and this wait's
55-
// result is discarded (`.catch(() => null)`); it only races the click.
56-
page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null),
57-
page.getByTestId('md-form-submit').click(),
58-
]);
59-
await page.waitForTimeout(500);
69+
const response = await submitBatch(page);
6070

61-
expect(batches.length).toBeGreaterThan(0);
62-
const parentOp = batches[0]?.operations?.[0];
71+
const parentOp = response.request().postDataJSON()?.operations?.[0];
6372
expect(parentOp?.object).toBe('showcase_project');
6473
expect(parentOp?.data?.name).toBe(name); // the form kept its value through submit
6574
expect(parentOp?.data?.account).toBeTruthy(); // lookup committed
6675
expect(parentOp?.data?.status).toBe('planned'); // Radix select committed
76+
77+
const [parentResult] = await batchResults(response);
78+
expect(parentResult?.id, 'the batch echoes the created parent id').toBeTruthy();
79+
80+
const stored = await readBack(request, 'showcase_project', parentResult.id);
81+
expect(stored?.name).toBe(name);
82+
expect(stored?.account).toBe(parentOp.data.account);
83+
expect(stored?.status).toBe('planned');
6784
});
6885

69-
test('Create with a task line includes the child op referencing the parent', async ({ page }) => {
70-
const batches = await captureBatch(page);
86+
test('Create with a task line stores the child referencing the parent', async ({ page, request }) => {
7187
const name = `E2E MD ${Date.now()}`;
7288
await page.locator('input[name="name"]').fill(name);
7389
await fillLookup(page, 'account', 'North');
74-
await selectOption(page, 'status', 'active');
90+
// `planned` is the only status `project_status_flow` admits on create; any
91+
// other pick is refused 400 `invalid_initial_state`.
92+
await selectOption(page, 'status', 'planned');
7593
await expect(page.getByText('Northwind', { exact: false })).toBeVisible();
7694

7795
// `showcase_task` is a rich child, so its relationship smart-default resolves
@@ -86,18 +104,26 @@ test('Create with a task line includes the child op referencing the parent', asy
86104
await editor.getByRole('button', { name: 'Apply', exact: true }).click();
87105
await expect(editor).toBeHidden();
88106

89-
await Promise.all([
90-
page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null),
91-
page.getByTestId('md-form-submit').click(),
92-
]);
93-
await page.waitForTimeout(500);
107+
const response = await submitBatch(page);
94108

95-
expect(batches.length).toBeGreaterThan(0);
96-
const ops = batches[0]?.operations ?? [];
109+
const ops = response.request().postDataJSON()?.operations ?? [];
97110
expect(ops[0]?.data?.name).toBe(name);
98111
// a child task op referencing the parent via $ref:0
99-
const child = ops.find((o: any) => o.object === 'showcase_task');
100-
expect(child).toBeTruthy();
101-
expect(child?.data?.title).toBe('E2E Task A');
102-
expect(child?.data?.project).toEqual({ $ref: 0 });
112+
const childIndex = ops.findIndex((o: any) => o.object === 'showcase_task');
113+
expect(childIndex).toBeGreaterThan(0);
114+
expect(ops[childIndex]?.data?.title).toBe('E2E Task A');
115+
expect(ops[childIndex]?.data?.project).toEqual({ $ref: 0 });
116+
117+
const results = await batchResults(response);
118+
const parentId = results[0]?.id;
119+
const childId = results[childIndex]?.id;
120+
expect(parentId, 'the batch echoes the created parent id').toBeTruthy();
121+
expect(childId, 'the batch echoes the created child id').toBeTruthy();
122+
123+
const parent = await readBack(request, 'showcase_project', parentId);
124+
expect(parent?.name).toBe(name);
125+
expect(parent?.status).toBe('planned');
126+
const child = await readBack(request, 'showcase_task', childId);
127+
expect(child?.title).toBe('E2E Task A');
128+
expect(child?.project).toBe(parentId);
103129
});

0 commit comments

Comments
 (0)