diff --git a/e2e/live/cascading-options.spec.ts b/e2e/live/cascading-options.spec.ts index 648d5c917d..5f65228948 100644 --- a/e2e/live/cascading-options.spec.ts +++ b/e2e/live/cascading-options.spec.ts @@ -53,7 +53,9 @@ test('province options re-filter live as country changes, and the stale value cl await page.getByRole('button', { name: /^(New|新建)$/i }).first().click(); const dialog = page.getByRole('dialog'); - await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); + // `showcase_cascade` has no child collection, so New opens the flat + // ModalForm: its own footer, not MasterDetailForm's `md-form-submit`. + await expect(dialog.getByTestId('modal-form-footer')).toBeVisible(); await dialog.locator('input[name="name"]').fill(`Cascade ${Date.now()}`); // --- country = China -> the dependent province offers only Chinese provinces. --- diff --git a/e2e/live/detail-related-list.spec.ts b/e2e/live/detail-related-list.spec.ts index 48a1d10e59..f12f7237fd 100644 --- a/e2e/live/detail-related-list.spec.ts +++ b/e2e/live/detail-related-list.spec.ts @@ -12,34 +12,36 @@ import { test, expect } from '@playwright/test'; * list is present. */ test('Account detail page auto-renders a relationship-derived Projects related list', async ({ page }) => { - // 1) Open the Accounts list and drill into the first record. + // 1) Open the Accounts list and drill into Northwind, a seeded account that + // owns projects (the status assertion below needs rows to read). await page.goto('/apps/showcase_app/showcase_account'); - // The data grid renders the primary (name) cell as a link. Clicking it opens - // the record (the list uses a split-pane drawer keyed by `?recordId=`). Grab - // the id and navigate to the canonical full-page detail route. - const firstRowNameLink = page.getByRole('row').nth(1).getByRole('link').first(); - await firstRowNameLink.waitFor({ state: 'visible', timeout: 15_000 }); - await firstRowNameLink.click(); + // The data grid renders the primary (name) cell as a link to the record's + // full-page detail route, `/record/`. Grab the id from that route. + const nameLink = page.getByRole('row').getByRole('link', { name: 'Northwind', exact: true }).first(); + await nameLink.waitFor({ state: 'visible', timeout: 15_000 }); + await nameLink.click(); - await page.waitForURL(/[?&]recordId=/, { timeout: 15_000 }); - const recordId = new URL(page.url()).searchParams.get('recordId'); + await page.waitForURL(/\/showcase_account\/record\/[^/?#]+/, { timeout: 15_000 }); + const recordId = new URL(page.url()).pathname.split('/record/')[1]?.split('/')[0]; expect(recordId).toBeTruthy(); // 2) Open the canonical full-page detail (synthesized record page). await page.goto(`/apps/showcase_app/showcase_account/record/${recordId}`); await expect(page).toHaveURL(/\/showcase_account\/record\//, { timeout: 15_000 }); - // 3) The synthesized detail page groups related lists under a "Related" tab. - // This tab only exists because `deriveRelatedLists` found the - // showcase_project.account relationship — it's the read-side mirror at work. - const relatedTab = page.getByRole('tab', { name: /^Related$/i }); - await relatedTab.waitFor({ state: 'visible', timeout: 15_000 }); - await relatedTab.click(); + // 3) `showcase_project.account` declares `relatedList: 'primary'`, so the + // synthesized page gives that list its own tab, titled by its declared + // `relatedListTitle` ("Projects"); non-primary lists share a "Related" + // tab. Either way the tab only exists because `deriveRelatedLists` found + // the relationship — it's the read-side mirror at work. + // (The tab's name gains a row-count badge once the count loads.) + const projectsTab = page.getByRole('tab', { name: /^Projects/ }); + await projectsTab.waitFor({ state: 'visible', timeout: 15_000 }); + await projectsTab.click(); - // 4) The relationship-derived related list renders under its declared - // `relatedListTitle` ("Projects"). - const relatedPanel = page.getByRole('tabpanel', { name: /Related/i }); + // 4) The relationship-derived related list renders in that tab. + const relatedPanel = page.getByRole('tabpanel', { name: /^Projects/ }); await expect(relatedPanel.getByText('Projects', { exact: false }).first()).toBeVisible({ timeout: 15_000 }); // The status column renders as a friendly, capitalized badge label (e.g. // "Planned"/"Active") — NOT the raw lowercase value ("planned"). This guards @@ -48,5 +50,5 @@ test('Account detail page auto-renders a relationship-derived Projects related l await expect( relatedPanel.getByText(/^(Planned|Active|On Hold|Completed|Cancelled)$/).first(), ).toBeVisible({ timeout: 15_000 }); - await expect(relatedPanel.getByText('planned', { exact: true })).toHaveCount(0); + await expect(relatedPanel.getByText(/^(planned|active|on_hold|completed|cancelled)$/)).toHaveCount(0); }); diff --git a/e2e/live/field-conditional-rules.spec.ts b/e2e/live/field-conditional-rules.spec.ts index c37a93cdc8..4e8c3d6ebe 100644 --- a/e2e/live/field-conditional-rules.spec.ts +++ b/e2e/live/field-conditional-rules.spec.ts @@ -41,18 +41,24 @@ test('header fields react to Status via CEL visibleWhen / readonlyWhen / require const paidOn = dialog.locator('[name="paid_on"]'); const taxRate = dialog.locator('[name="tax_rate"]'); + // A read-only field renders its value as text inside the form's read-only + // field group, in place of the editor — so "locked" is: the input is gone + // and that group is shown. + const taxRateReadonly = dialog.locator('[data-field="tax_rate"] [data-slot="readonly-field-group"]'); // --- Initial (status unset / draft): paid_on hidden, tax_rate editable, // issued_on not yet required. --- await expect(paidOn).toHaveCount(0); await expect(taxRate).toBeEnabled(); + await expect(taxRateReadonly).toHaveCount(0); expect(await isRequired(dialog, 'Issued On')).toBe(false); // --- Paid: tax_rate locks (readonlyWhen), paid_on appears + required, // issued_on required. --- await selectOption(dialog, 'status', 'paid'); await expect(paidOn).toHaveCount(1); - await expect(taxRate).toBeDisabled(); + await expect(taxRate).toHaveCount(0); + await expect(taxRateReadonly).toBeVisible(); expect(await isRequired(dialog, 'Paid On')).toBe(true); expect(await isRequired(dialog, 'Issued On')).toBe(true); @@ -61,6 +67,7 @@ test('header fields react to Status via CEL visibleWhen / readonlyWhen / require await selectOption(dialog, 'status', 'sent'); await expect(paidOn).toHaveCount(0); await expect(taxRate).toBeEnabled(); + await expect(taxRateReadonly).toHaveCount(0); expect(await isRequired(dialog, 'Issued On')).toBe(true); // --- Draft: everything relaxes — tax_rate editable, paid_on hidden, @@ -68,5 +75,6 @@ test('header fields react to Status via CEL visibleWhen / readonlyWhen / require await selectOption(dialog, 'status', 'draft'); await expect(paidOn).toHaveCount(0); await expect(taxRate).toBeEnabled(); + await expect(taxRateReadonly).toHaveCount(0); expect(await isRequired(dialog, 'Issued On')).toBe(false); }); diff --git a/e2e/live/form-view-subforms.spec.ts b/e2e/live/form-view-subforms.spec.ts index 4f2a95b529..9485f378ab 100644 --- a/e2e/live/form-view-subforms.spec.ts +++ b/e2e/live/form-view-subforms.spec.ts @@ -29,7 +29,9 @@ test('New modal renders relationship-derived subforms and submits an at const dialog = page.getByRole('dialog'); await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); - await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible(); + // The section's heading, by role: a bare text match also hits the dialog's + // sr-only description ("…its line items, then save.") and fails strict mode. + await expect(dialog.getByRole('heading', { name: 'Line Items' })).toBeVisible(); const name = `INV-${Date.now()}`; await dialog.locator('input[name="name"]').fill(name); diff --git a/e2e/live/grid-file-upload.spec.ts b/e2e/live/grid-file-upload.spec.ts index feb5ff3981..abcdfc2fb7 100644 --- a/e2e/live/grid-file-upload.spec.ts +++ b/e2e/live/grid-file-upload.spec.ts @@ -15,7 +15,10 @@ import { test, expect } from '@playwright/test'; * removable chip), never a text ``; * • picking a file uploads it through the console's UploadProvider adapter and * shows a chip with the file name; - * • submit carries the resolved file object ({ name, url, … }) on the line. + * • submit carries the uploaded file's id on the line — ADR-0104's stored + * form for a file field is an opaque file id, never an inline + * { name, url } blob — and that id resolves through the file door + * (`GET /api/v1/storage/files/:fileId`) to the bytes that were uploaded. * * Live-only (needs the storage service + a real backend); runs under * `pnpm test:e2e:live`, not the mocked PR e2e job. @@ -27,7 +30,9 @@ const PNG = Buffer.from( 'base64', ); -test('inline line-item grid uploads a per-row file and persists it in the batch', async ({ page }) => { +const API = process.env.LIVE_API_URL || 'http://localhost:3000'; + +test('inline line-item grid uploads a per-row file and persists it in the batch', async ({ page, request }) => { const batches: any[] = []; page.on('request', (r) => { if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) { @@ -40,7 +45,9 @@ test('inline line-item grid uploads a per-row file and persists it in the batch' const dialog = page.getByRole('dialog'); await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); - await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible(); + // The section's heading, by role: a bare text match also hits the dialog's + // sr-only description ("…its line items, then save.") and fails strict mode. + await expect(dialog.getByRole('heading', { name: 'Line Items' })).toBeVisible(); const li = dialog.getByTestId('line-items'); // The child schema loads async — wait for real headers, then assert Receipt. @@ -84,9 +91,14 @@ test('inline line-item grid uploads a per-row file and persists it in the batch' const ops = batches[0].operations; const child = ops.find((o: any) => o.object === 'showcase_invoice_line'); expect(child).toBeTruthy(); - // The uploaded file resolved to a stored object, not a blob/text placeholder. + // The line carries the uploaded file's id (ADR-0104 D3, "Stored form": an + // opaque file id), not an inline blob, a url, or the file name as text. const receipt = child?.data?.receipt; - expect(receipt).toBeTruthy(); - expect(receipt.name || receipt.original_name).toContain('receipt'); - expect(String(receipt.url)).toMatch(/^https?:\/\//); + expect(typeof receipt, `the receipt is not a file id: ${JSON.stringify(receipt)}`).toBe('string'); + expect(receipt).not.toContain('receipt.png'); + // The id resolves through the file door to exactly the bytes uploaded above. + const file = await request.get(`${API}/api/v1/storage/files/${encodeURIComponent(receipt)}`); + expect(file.status()).toBe(200); + expect(file.headers()['content-type']).toContain('image/png'); + expect(Buffer.compare(await file.body(), PNG)).toBe(0); }); diff --git a/e2e/live/inline-edit-polish-2572.spec.ts b/e2e/live/inline-edit-polish-2572.spec.ts index 84e0677df0..4cc1557d47 100644 --- a/e2e/live/inline-edit-polish-2572.spec.ts +++ b/e2e/live/inline-edit-polish-2572.spec.ts @@ -9,15 +9,20 @@ import { join } from 'node:path'; * 1. $expand-ed lookup values pass through to the picker — the Account chip * shows "Northwind" with ZERO hydration findOne fetches; * 2. approval-lock preflight — saving a budget change fires - * `showcase_budget_approval`, and the re-read lock state hides the inline - * edit affordances (enter() no-ops); + * `showcase_budget_approval`. Its first step (`manager_review`) declares + * `lockRecord: false`, so the re-read approval state reads "In approval · + * editable" and the inline edit affordances stay live. Approving that step + * routes a budget above 500k to `exec_review` (`lockRecord: true`), and + * the re-read lock state hides the inline edit affordances (enter() + * no-ops); * 3. currency edits in a real number input carrying metadata min/step; * 4. the header Edit CTA is disabled while the inline session is live; * 5. Esc cancels / Ctrl+Enter saves the shared session. * * Prereqs: showcase backend + console dev server (see playwright.live.config.ts; - * override via LIVE_APP_URL / LIVE_API_URL). The budget mutation intentionally - * locks the record — run against a throwaway database. + * override via LIVE_APP_URL / LIVE_API_URL). The test creates its own project; + * its budget change opens an approval request on it, and the test approves the + * first step so the record ends locked — run against a throwaway database. */ const APP = process.env.SHOWCASE_APP || 'com.example.showcase'; @@ -63,21 +68,42 @@ function authToken(): string { test('record inline-edit polish (#2572) — showcase Project end-to-end', async ({ page, request }) => { test.setTimeout(240_000); - // ── Resolve the "Website Relaunch" record id via the API (authoritative). ── + // ── A project of its own, created through the API (authoritative). The + // budget change below opens an approval request and moves the budget, so + // driving the seeded "Website Relaunch" spent it: a second run met a + // different budget and an already-pending request. Same shape as that + // seed row — Northwind's account, a 150k budget, 60k spent — and born + // `planned`, the status flow's only entry state. ── const token = authToken(); - const listRes = await request.get(`${API}/api/v1/data/showcase_project?$top=50`, { - headers: { Authorization: `Bearer ${token}` }, + const authHeader = { Authorization: `Bearer ${token}` }; + const accountsRes = await request.get(`${API}/api/v1/data/showcase_account?$top=50`, { + headers: authHeader, + }); + expect(accountsRes.ok()).toBeTruthy(); + const accountsBody = await accountsRes.json(); + const accounts: any[] = accountsBody?.records ?? accountsBody?.data ?? []; + const northwind = accounts.find((r) => r.name === 'Northwind'); + expect(northwind, 'seeded "Northwind" account exists').toBeTruthy(); + const projectName = `Inline Polish ${Date.now()}`; + const createRes = await request.post(`${API}/api/v1/data/showcase_project`, { + headers: authHeader, + data: { + name: projectName, + account: northwind.id ?? northwind._id, + status: 'planned', + health: 'green', + budget: 150000, + spent: 60000, + }, }); - expect(listRes.ok()).toBeTruthy(); - const listBody = await listRes.json(); - const rows: any[] = listBody?.records ?? listBody?.data ?? []; - const record = rows.find((r) => r.name === 'Website Relaunch'); - expect(record, 'seeded "Website Relaunch" project exists').toBeTruthy(); - const recordId = record.id ?? record._id; + expect(createRes.ok(), await createRes.text()).toBeTruthy(); + const created = await createRes.json(); + const recordId = created?.id ?? created?.record?.id ?? created?.data?.id; + expect(recordId, 'created project id').toBeTruthy(); // ── Open the record page. ── await page.goto(`/apps/${APP}/showcase_project/record/${recordId}`); - await expect(page.getByRole('heading', { name: /Website Relaunch/ }).first()).toBeVisible({ + await expect(page.getByRole('heading', { name: projectName }).first()).toBeVisible({ timeout: 30_000, }); @@ -126,7 +152,9 @@ test('record inline-edit polish (#2572) — showcase Project end-to-end', async await budgetGroup.locator(`[title="${EDIT_HINT}"]`).dblclick(); await expect(saveBtn).toBeVisible(); const budgetInput2 = page.locator('input[type="number"][min="0"][step="0.01"]').first(); - await budgetInput2.fill('160000'); + // Above the flow's 500k decision edge, so approving `manager_review` routes + // the request on to the locking `exec_review` step (item 2). + await budgetInput2.fill('600000'); const patchPromise = page.waitForResponse( (res) => res.request().method() === 'PATCH' && @@ -137,41 +165,79 @@ test('record inline-edit polish (#2572) — showcase Project end-to-end', async await page.keyboard.press('Control+Enter'); const patchRes = await patchPromise; const patchBody = patchRes.request().postDataJSON?.() ?? {}; - expect(Number(patchBody.budget), 'PATCH carries the edited budget').toBe(160000); + expect(Number(patchBody.budget), 'PATCH carries the edited budget').toBe(600000); expect(patchBody, 'PATCH carries ONLY the edited key').not.toHaveProperty('spent'); await expect(saveBtn, 'session ends after a successful save').toBeHidden({ timeout: 15_000 }); - // ── Item 2: the budget change (>100k, changed) fires showcase_budget_approval → - // a pending request opens and the re-read approval state locks the session: - // every pencil/double-click affordance disappears. (This backend does NOT - // materialize `approval_status` on the record — the lock signal is the - // pending request from the approvals API, which is exactly the second - // branch of RecordDetailView's `approvalLocked`.) ── - const authHeader = { Authorization: `Bearer ${token}` }; + // ── Item 2: the budget change (>100k, changed) fires showcase_budget_approval. + // (This backend does NOT materialize `approval_status` on the record — the + // lock signal is the pending request from the approvals API, the second + // branch of RecordDetailView's approval state, and its `lock_record` flag + // carries the step's `lockRecord` policy.) ── + /** The approvals API's request row, as far as this test reads it. */ + type ApprovalRequestRow = { id: string; status: string; flow_node_id?: string; lock_record?: boolean }; + const pendingRequests = async () => { + const res = await request.get( + `${API}/api/v1/approvals/requests?object=showcase_project&recordId=${recordId}`, + { headers: authHeader }, + ); + expect(res.ok(), await res.text()).toBeTruthy(); + const body = await res.json(); + return ((body?.data ?? []) as ApprovalRequestRow[]).filter((r) => r.status === 'pending'); + }; + const budgetGroupOnPage = () => + page.locator('div.group').filter({ has: page.getByText('Budget', { exact: true }) }).last(); + + // 2a. The request opens on its first step, `manager_review`, which declares + // `lockRecord: false` — the showcase's unlocked-step fixture + // (objectui#2902). The re-read approval state reads "In approval · + // editable" and the inline edit affordances stay live. + let managerReview: ApprovalRequestRow | undefined; await expect .poll( async () => { - const res = await request.get( - `${API}/api/v1/approvals/requests?object=showcase_project&recordId=${recordId}`, - { headers: authHeader }, - ); - const body = await res.json(); - return (body?.data ?? []).some((r: any) => r.status === 'pending'); + managerReview = (await pendingRequests()).find((r) => r.flow_node_id === 'manager_review'); + return managerReview?.lock_record; }, { timeout: 60_000, intervals: [2_000] }, ) + .toBe(false); + + await page.reload(); + await page.getByRole('heading', { name: projectName }).first().waitFor({ timeout: 20_000 }); + await expect( + page.getByRole('status').filter({ hasText: 'In approval · editable' }), + 'an unlocked approval step reads "In approval · editable"', + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Locked for approval')).toHaveCount(0); + // A double-click on the budget still opens an inline-edit session. + await budgetGroupOnPage().locator(`[title="${EDIT_HINT}"]`).dblclick(); + await expect(saveBtn, 'inline editing stays live during an unlocked approval').toBeVisible(); + await page.keyboard.press('Escape'); + await expect(saveBtn).toBeHidden(); + + // 2b. Approve `manager_review` through the approvals API (the seeded admin + // holds the `manager` position). The budget is above 500k, so the run + // moves on to `exec_review`, which declares `lockRecord: true`. + const approveRes = await request.post(`${API}/api/v1/approvals/requests/${managerReview!.id}/approve`, { + headers: authHeader, + data: { comment: 'e2e: route the request on to exec_review' }, + }); + expect(approveRes.ok(), await approveRes.text()).toBeTruthy(); + await expect + .poll( + async () => (await pendingRequests()).find((r) => r.flow_node_id === 'exec_review')?.lock_record, + { timeout: 60_000, intervals: [2_000] }, + ) .toBe(true); - // Fresh mount reads the pending request → locked → zero inline-edit + // Fresh mount reads the locking request → locked → zero inline-edit // affordances anywhere on the page… await expect .poll( async () => { await page.reload(); - await page - .getByRole('heading', { name: /Website Relaunch/ }) - .first() - .waitFor({ timeout: 20_000 }); + await page.getByRole('heading', { name: projectName }).first().waitFor({ timeout: 20_000 }); // Editable-record pages render the hint; wait a beat for hydration // so an empty count means "locked", not "not yet rendered". await page.waitForTimeout(1_500); @@ -180,12 +246,9 @@ test('record inline-edit polish (#2572) — showcase Project end-to-end', async { timeout: 60_000, intervals: [2_000] }, ) .toBe(0); + await expect(page.getByText('Locked for approval').first()).toBeVisible(); // …and a double-click on the (former) budget field must NOT open a session. - const lockedBudgetGroup = page - .locator('div.group') - .filter({ has: page.getByText('Budget', { exact: true }) }) - .last(); - await lockedBudgetGroup.dblclick(); + await budgetGroupOnPage().dblclick(); await expect(saveBtn).toBeHidden(); }); diff --git a/e2e/live/record-history-display.spec.ts b/e2e/live/record-history-display.spec.ts index 2c4d8a674f..7ebc45106c 100644 --- a/e2e/live/record-history-display.spec.ts +++ b/e2e/live/record-history-display.spec.ts @@ -8,48 +8,42 @@ import { test, expect, type APIRequestContext } from '@playwright/test'; * payloads: ISO datetimes ("2026-08-04T12:00:00.000Z"), raw lookup id * arrays, raw select values, and "Unknown user" for every entry. * - * Needs an object with `enable.trackHistory: true`; the showcase app has - * none, so this spec targets `todo_task` from examples/app-todo and SKIPS - * when that backend isn't the live target: - * - * cd ../objectstack/examples/app-todo && pnpm exec objectstack dev --seed-admin --fresh -p 4015 - * cd apps/console && DEV_PROXY_TARGET=http://localhost:4015 pnpm exec vite --port 5196 --strictPort - * LIVE_APP_URL=http://localhost:5196 LIVE_API_URL=http://localhost:4015 pnpm test:e2e:live record-history-display + * Targets `showcase_account`, the showcase's `enable.trackHistory` object — + * the console gates the History tab on that flag. Its audited field mix: two + * selects (`industry`, and `status` labelled "Lifecycle"), a date + * (`signed_on`, "Customer Since") and the record owner, a user lookup + * (`owner_id`). No history-tracked showcase object carries a boolean or a + * datetime field, so booleans-as-words and datetime localization are not + * exercised here; the unit suite `auditHistoryDisplay.test.ts` in app-shell + * pins both ("localizes booleans through t with Yes/No fallback", "localizes + * datetime values instead of raw ISO strings"). */ const API = process.env.LIVE_API_URL || 'http://localhost:3000'; +const EMAIL = process.env.LIVE_EMAIL || 'admin@objectos.ai'; -async function hasTodoTask(request: APIRequestContext): Promise { - const res = await request.get(`${API}/api/v1/meta/object/todo_task`); - return res.ok(); -} +type SysUser = { id: string; name: string; email?: string }; -async function adminUser(request: APIRequestContext): Promise<{ id: string; name: string }> { +/** The signed-in user (the audit actor) and one other seeded user. */ +async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser; other: SysUser }> { const body = await ( - await request.get(`${API}/api/v1/data/sys_user?$top=1&$select=id,name`) + await request.get(`${API}/api/v1/data/sys_user?$top=20&$select=id,name,email`) ).json(); - const row = (body.data ?? body.records ?? body)[0]; - expect(row?.id, 'a seeded sys_user').toBeTruthy(); - return { id: row.id, name: row.name }; + const rows: SysUser[] = body.data ?? body.records ?? body; + const actor = rows.find((r) => r.email === EMAIL); + const other = rows.find((r) => r.id !== actor?.id); + expect(actor?.id, 'the signed-in sys_user').toBeTruthy(); + expect(other?.id, 'a second seeded sys_user to hand the record to').toBeTruthy(); + return { actor: actor!, other: other! }; } test('History tab shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => { - test.skip(!(await hasTodoTask(request)), 'live backend has no todo_task (app-todo) — skipping'); - - const admin = await adminUser(request); + const { actor, other } = await seededUsers(request); - // 1) Seed a task and drive three audited updates through the real API — - // the same field mix the QA report flagged: dates, a select, a lookup, - // and a boolean. - const subject = `历史显示 e2e ${Date.now()}`; - const createRes = await request.post(`${API}/api/v1/data/todo_task`, { - data: { - subject, - status: 'not_started', - priority: 'high', - due_date: '2026-07-26', - reminder_date: '2026-07-27T12:00:00.000Z', - is_recurring: false, - }, + // 1) Seed an account and drive three audited updates through the real API — + // the field mix the QA report flagged: a date, selects and a lookup. + const name = `History display e2e ${Date.now()}`; + const createRes = await request.post(`${API}/api/v1/data/showcase_account`, { + data: { name, industry: 'technology', status: 'prospect', signed_on: '2026-07-26' }, }); expect(createRes.ok()).toBeTruthy(); const created = await createRes.json(); @@ -57,41 +51,49 @@ test('History tab shows display values (localized dates, option labels, resolved expect(id).toBeTruthy(); for (const patch of [ - { due_date: '2026-08-04', reminder_date: '2026-08-06T00:00:00.000Z' }, - { status: 'in_progress', owner: admin.id }, - { is_recurring: true, recurrence_type: 'weekly', recurrence_interval: 1 }, + { signed_on: '2026-08-04' }, + { status: 'active', industry: 'finance' }, + // The creator already owns a new record, so the owner moves to someone else. + { owner_id: other.id }, ]) { - const res = await request.patch(`${API}/api/v1/data/todo_task/${id}`, { data: patch }); + const res = await request.patch(`${API}/api/v1/data/showcase_account/${id}`, { data: patch }); expect(res.ok()).toBeTruthy(); } // 2) Open the synthesized record page and its History tab. - await page.goto(`/apps/todo_app/todo_task/record/${id}`); + await page.goto(`/apps/showcase_app/showcase_account/record/${id}`); const historyTab = page.getByRole('tab', { name: /^(History|历史)$/ }); await historyTab.waitFor({ state: 'visible', timeout: 15_000 }); await historyTab.click(); const panel = page.getByRole('tabpanel', { name: /History|历史/ }); // The three UPDATE entries render as diff lines; wait for the latest one. - await expect(panel.getByText('Recurrence Type', { exact: false }).first()).toBeVisible({ timeout: 15_000 }); + await expect(panel.getByText('Owner', { exact: false }).first()).toBeVisible({ timeout: 15_000 }); const text = (await panel.innerText()).replace(/\s+/g, ' '); // 3) Actor attribution: the audit user resolves to a display name — // never the raw id, never the unknown-user fallback. - expect(text).toContain(admin.name); - expect(text).not.toContain(admin.id); + expect(text).toContain(actor.name); + expect(text).not.toContain(actor.id); expect(text).not.toMatch(/Unknown user|未知用户/); // 4) Select values render as option labels, not raw stored values. - expect(text).toMatch(/Not Started/); - expect(text).toMatch(/In Progress/); - expect(text).not.toMatch(/\bnot_started\b|\bin_progress\b/); + expect(text).toMatch(/Prospect/); + expect(text).toMatch(/Active/); + expect(text).toMatch(/Technology/); + expect(text).toMatch(/Finance/); + expect(text).not.toMatch(/\bprospect\b|\bactive\b|\btechnology\b|\bfinance\b/); - // 5) Dates/datetimes are localized — no raw ISO payloads anywhere. + // 5) Dates are localized — no raw ISO payloads anywhere, neither a datetime + // nor the stored YYYY-MM-DD of the date field. The date's diff line is + // there to read, so the absence below is not vacuous. + expect(text).toMatch(/Customer Since/); expect(text).not.toMatch(/T\d{2}:\d{2}:\d{2}\.\d{3}Z/); + expect(text).not.toMatch(/\b\d{4}-\d{2}-\d{2}\b/); - // 6) The owner lookup resolves to the user's name on the diff line (its id - // is already asserted absent above), and booleans render as words. - expect(text).toMatch(/Assigned To/i); - expect(text).toMatch(/Yes|是/); + // 6) The owner lookup resolves to the users' names on the diff line, never + // to their ids. + expect(text).toMatch(/Owner/); + expect(text).toContain(other.name); + expect(text).not.toContain(other.id); }); diff --git a/e2e/live/studio-editor.spec.ts b/e2e/live/studio-editor.spec.ts index e442b4c1a2..19f9cc1316 100644 --- a/e2e/live/studio-editor.spec.ts +++ b/e2e/live/studio-editor.spec.ts @@ -1,30 +1,82 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type APIRequestContext } from '@playwright/test'; /** * Live e2e for the Studio metadata page editor — guards the "Add block works * and blocks are configurable" contract the SDUI runtime depends on. Drives the * real editor at /apps/:app/metadata/page/:name against the running stack. + * + * The page under edit is one the test authors in the environment: a copy of the + * showcase's `showcase_project_workspace` (its regions and blocks) under a fresh + * name, removed again at the end. The shipped page itself opens read-only, by + * design: `page` is a create-only type (`allowEnvironmentOverlay: false`, + * `allowRuntimeCreate: true`), and under ADR-0005's two-tier rule an item a code + * package ships stays immutable there while a page the environment authors is + * editable. + * + * The page's layout — its regions and the blocks in them — is authored on the + * 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. */ -const EDIT = '/apps/showcase_app/metadata/page/showcase_project_workspace'; - -test('the page editor loads with its config sections', async ({ page }) => { - await page.goto(EDIT); - await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Basics' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Layout' })).toBeVisible(); - await expect(page.getByRole('button', { name: /Add block/i }).first()).toBeVisible(); +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 }> { + 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; + // 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 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 }; +} + +async function removePage(request: APIRequestContext, name: string) { + await request.delete(`${API}/api/v1/meta/page/${name}`); +} + +test('the page editor loads with its config sections', async ({ page, request }) => { + const { name, regions } = await authorEditablePage(request); + try { + await page.goto(`/apps/showcase_app/metadata/page/${name}`); + await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); + // The environment's own page: no lock stands between the test and the editor. + await expect(page.getByText(/is locked and cannot be edited/i)).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Basics' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Data Context' })).toBeVisible(); + // Layout: every region renders on the canvas with its own Add block, and + // the canvas offers Add region. + await expect(page.getByRole('button', { name: /Add block/i })).toHaveCount(regions); + await expect(page.getByRole('button', { name: /Add region/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Add block/i }).first()).toBeVisible(); + } finally { + await removePage(request, name); + } }); -test('Add block opens a block-type picker with configurable block kinds', async ({ page }) => { - await page.goto(EDIT); - await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); +test('Add block opens a block-type picker with configurable block kinds', async ({ page, request }) => { + const { name } = await authorEditablePage(request); + try { + await page.goto(`/apps/showcase_app/metadata/page/${name}`); + await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); - await page.getByRole('button', { name: /Add block/i }).first().click(); + await page.getByRole('button', { name: /Add block/i }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - // The picker offers selectable, schema-backed block kinds (SDUI slots). - 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(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // The picker offers selectable, schema-backed block kinds (SDUI slots). + 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(); + } finally { + await removePage(request, name); + } }); diff --git a/e2e/live/studio-object-designer.spec.ts b/e2e/live/studio-object-designer.spec.ts index 5967807df3..58a7f1cbf0 100644 --- a/e2e/live/studio-object-designer.spec.ts +++ b/e2e/live/studio-object-designer.spec.ts @@ -15,9 +15,12 @@ import { test, expect, type Page } from '@playwright/test'; * F4 — empty picklist row must not trip spec validation */ -const STUDIO = '/apps/com.objectstack.studio'; +// The object designer is reached through the showcase's own app: a stock +// showcase boot does not serve the Studio app (`com.objectstack.studio`; the +// served apps are what `GET /api/v1/meta/app` lists), and the metadata routes +// resolve under any served app. const PKG = 'com.example.showcase'; -const NEW_OBJ = `${STUDIO}/metadata/object/new?package=${PKG}`; +const NEW_OBJ = `/apps/showcase_app/metadata/object/new?package=${PKG}`; /** Add a field of the given palette type into the open object designer. */ async function addField(page: Page, typeName: string) { @@ -53,6 +56,11 @@ test('F4: adding a picklist + an empty option row does not trip spec validation' 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 @@ -64,17 +72,30 @@ test('F4: adding a picklist + an empty option row does not trip spec validation' }); test('F1: a newly created package appears in the switcher without a reload', async ({ page }) => { - const pkgId = `com.e2e.dogfood${Date.now()}`; - const pkgName = `E2E Dogfood ${Date.now()}`; - await page.goto(`${STUDIO}/component/developer/packages`); - await page.getByRole('button', { name: /New Package/i }).click(); - await page.getByTestId('package-id-input').fill(pkgId); - await page.getByTestId('package-name-input').fill(pkgName); - await page.getByRole('button', { name: /Create package/i }).click(); + const stamp = Date.now(); + const pkgId = `com.e2e.dogfood${stamp}`; + const pkgName = `E2E Dogfood ${stamp}`; + const pkgIdPattern = pkgId.replace(/\./g, '\\.'); + // The console's Studio surface (`/studio/:packageId/:tab`) carries the + // package switcher on a stock boot. The sidebar switcher (`package-switcher`) + // renders only for an app that declares `contextSelectors`, and no app a + // stock showcase boot serves declares one. + await page.goto(`/studio/${PKG}/data`); + const chip = page.getByTestId('studio-pkg-chip'); + await chip.click(); + await page.getByRole('button', { name: /New package/i }).click(); + await page.getByRole('dialog').getByLabel(/^Display name/).first().fill(pkgName); + await page.getByTestId('package-form-id-input').fill(pkgId); + await page.getByTestId('package-form-submit').click(); - // No reload: open the sidebar package switcher and expect the new package. - await page.getByTestId('package-switcher').click(); - await expect(page.getByRole('option', { name: pkgName })).toBeVisible(); + // No reload: the surface moves into the new package, and the switcher's chip + // names it from the refreshed list (a stale list falls back to the raw id)… + await expect(page).toHaveURL(new RegExp(`/studio/${pkgIdPattern}/`)); + await expect(chip).toContainText(pkgName); + // …and the open switcher lists it by its name, which only the refreshed list + // carries (each entry's accessible name is its name, then its id). + await chip.click(); + await expect(page.getByRole('button', { name: new RegExp(`${pkgName}\\s*${pkgIdPattern}`) })).toBeVisible(); // Cleanup so we don't pollute the shared dev stack. await page.evaluate(async (id) => { diff --git a/e2e/live/studio-record-page.spec.ts b/e2e/live/studio-record-page.spec.ts index 181b2562c1..3844a60ccd 100644 --- a/e2e/live/studio-record-page.spec.ts +++ b/e2e/live/studio-record-page.spec.ts @@ -24,10 +24,18 @@ test('Studio: a new record page is created bound to its object and seeded from t await page.getByLabel(/^Label/i).first().waitFor({ state: 'visible', timeout: 20_000 }); const uniq = Date.now().toString().slice(-6); - // Label slugifies into Name; type defaults to 'record'; bind the object. + // Label slugifies into Name; bind the object. Page type defaults to 'list' + // (the interface page, the most-built type), so a record page is picked. await page.getByLabel(/^Label/i).first().fill(`Invoice Page ${uniq}`); - await page.getByLabel(/^Object/i).first().fill('showcase_invoice'); - await page.waitForTimeout(500); + await page.getByLabel(/^Page type/i).click(); + await page.getByRole('option', { name: 'record', exact: true }).click(); + await expect(page.getByLabel(/^Page type/i)).toContainText('record'); + // The Object control is a select (`role=combobox`) listing the objects, not a + // text input. Anchored on the whole label: `/^Object/` also matches the app + // bar's "ObjectOS home" link. + await page.getByLabel(/^Object$/i).click(); + await page.getByRole('option', { name: 'showcase_invoice', exact: true }).click(); + await expect(page.getByLabel(/^Object$/i)).toContainText('showcase_invoice'); await Promise.all([ page.waitForRequest((r) => r.method() === 'PUT' && /\/meta\/page\//.test(r.url()), { timeout: 15_000 }).catch(() => null), diff --git a/e2e/live/user-filters.spec.ts b/e2e/live/user-filters.spec.ts index aa52b2b8de..ab50146bcf 100644 --- a/e2e/live/user-filters.spec.ts +++ b/e2e/live/user-filters.spec.ts @@ -6,11 +6,13 @@ import { test, expect } from '@playwright/test'; * Covers the behaviors hand-verified during the ADR-0047 rounds so they * cannot silently regress: * - * 1. Data mode (showcase_task "All Tasks"): metadata filter TABS render and - * dropdowns are suppressed (tabs XOR dropdowns); picking a tab filters + * 1. Interface mode, tabs element (Task Triage page): preset filter TABS + * render and dropdowns do not (tabs XOR dropdowns); picking a tab filters * the grid and persists as `uf__tab` in the URL; reload restores it. - * 2. Data mode visualization switcher: a compact dropdown in the toolbar's - * right cluster (single row), offering only the whitelist. + * ADR-0053 made tabs an interface-page element: on the object's default + * list ("views" mode) the same presets are named views in the switcher. + * 2. Data mode visualization switcher (showcase_task "All Tasks"): a compact + * dropdown in the toolbar's Display popover, offering only the whitelist. * 3. Interface mode (Task Workbench page): author-enabled dropdown filters * only — no view tabs, no visualization switcher (single-entry * whitelist = locked); selecting a value filters rows, persists as @@ -18,16 +20,15 @@ import { test, expect } from '@playwright/test'; * while the selection is active (counts snapshot). */ -test.describe('ADR-0047 data mode — filter tabs + viz dropdown', () => { +test.describe('ADR-0053 interface mode — Task Triage tabs element', () => { test('filter tabs render exclusively, persist to URL, and restore on reload', async ({ page }) => { - await page.goto('/apps/showcase_app/showcase_task'); + await page.goto('/apps/showcase_app/page/showcase_task_triage'); - // The default "All Tasks" view configures metadata tabs — they render… - // (TabBar renders role="tab" with stable view-tab- testids) - const urgentTab = page.getByTestId('view-tab-urgent'); + // The page's `userFilters.element: 'tabs'` renders preset tabs… + const urgentTab = page.getByTestId('filter-tab-urgent'); await expect(urgentTab).toBeVisible(); - // …and dropdown filter badges do not (tabs XOR dropdowns). - await expect(page.getByTestId('filter-badge-status')).toHaveCount(0); + // …and no dropdown filter badges (tabs XOR dropdowns). + await expect(page.locator('[data-testid^="filter-badge-"]')).toHaveCount(0); // Picking a tab filters the grid and mirrors into the URL. await urgentTab.click(); @@ -39,22 +40,29 @@ test.describe('ADR-0047 data mode — filter tabs + viz dropdown', () => { await expect(page).toHaveURL(/uf__tab=urgent/); await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 }); }); +}); +test.describe('ADR-0047 data mode — viz dropdown', () => { test('visualization switcher is a single dropdown offering the whitelist', async ({ page }) => { await page.goto('/apps/showcase_app/showcase_task'); + // The switcher lives in the toolbar's Display popover. + await page.getByTestId('toolbar-display-trigger').click(); const trigger = page.getByTestId('view-switcher-dropdown'); await expect(trigger).toBeVisible(); await trigger.click(); - - // Whitelist: grid, kanban, gallery, calendar — and nothing else. - await expect(page.getByRole('button', { name: 'Kanban' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Gallery' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Calendar' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Timeline' })).toHaveCount(0); - - // Switching actually swaps the renderer (kanban board appears). - await page.getByRole('button', { name: 'Kanban' }).click(); + // The open switcher's own list, found through the trigger's aria-controls. + const menu = page.locator(`[id="${await trigger.getAttribute('aria-controls')}"]`); + + // Whitelist: the default task list's `appearance.allowedVisualizations` + // (grid, kanban, gallery, calendar, timeline, gantt) — and nothing else. + // Map and chart are declared on this object as their own named views and + // stay out of the switcher. + await expect(menu.getByRole('button')).toHaveText(['Grid', 'Kanban', 'Gallery', 'Calendar', 'Timeline', 'Gantt']); + + // Switching actually swaps the renderer; the trigger names the new one. + await menu.getByRole('button', { name: 'Kanban' }).click(); + await page.getByTestId('toolbar-display-trigger').click(); await expect(trigger).toContainText('Kanban'); }); }); @@ -82,8 +90,9 @@ test.describe('ADR-0047 interface mode — Task Workbench', () => { await expect(async () => { expect(await headers.count()).toBeGreaterThan(2); }).toPass({ timeout: 15000 }); - // The title/assignee business fields are present (not only "#"). - await expect(page.getByRole('cell', { name: /example\.com/ }).first()).toBeVisible(); + // The title/assignee business fields are present (not only "#"). The table + // is `role="grid"`, so its cells are gridcells. + await expect(page.getByRole('gridcell', { name: /example\.com/ }).first()).toBeVisible(); }); test('dropdown selection filters, keeps counts, persists, and restores', async ({ page }) => { @@ -119,43 +128,3 @@ test.describe('ADR-0047 interface mode — Task Workbench', () => { await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 }); }); }); - -test.describe('ADR-0047 lookup quick filter (CRM parity)', () => { - // The Task List view exposes `project` (a master-detail lookup) as a - // quick filter — the record-picker dropdown path hotcrm's account - // workbench (owner: user lookup) depends on. Requires the showcase - // metadata from framework feat/adr-0047-lookup-filter-example or later. - test('lookup field renders a record picker and filters by reference', async ({ page }) => { - await page.goto('/apps/showcase_app/showcase_task/view/showcase_task.tabular'); - - // Wait for the filter bar to render before probing for the overflow. - await expect(page.getByTestId('filter-badge-status')).toBeVisible({ timeout: 15000 }); - - const projectBadge = page.getByTestId('filter-badge-project'); - // maxVisible folds the 4th field into the "More" overflow when present. - if (!(await projectBadge.isVisible().catch(() => false))) { - const more = page.getByTestId('user-filters').getByRole('button', { name: /More|更多/ }); - if (!(await more.isVisible().catch(() => false))) { - test.skip(true, 'showcase metadata without the project lookup filter — skipping'); - } - await more.click(); - } - await projectBadge.click(); - - // The lookup picker renders with a search box and record options. - const picker = page.getByTestId('filter-lookup-project'); - await expect(picker).toBeVisible(); - // The picker is a trigger button that opens the record search list; - // records render as multi-select checkboxes. - await picker.getByRole('button', { name: /Search|搜索/ }).click(); - const option = page.getByRole('checkbox', { name: 'Website Relaunch' }); - await expect(option).toBeVisible(); - - // Picking a record narrows the list and persists as a uf_* param. - await option.click(); - await expect(page).toHaveURL(/uf_project=/); - await expect(page.getByTestId('record-count-bar')).toContainText(/\d+/, { timeout: 15000 }); - // The badge reflects one active selection. - await expect(page.getByTestId('filter-badge-project')).toContainText('1'); - }); -});