diff --git a/examples/app-showcase/e2e/detail-shapes.spec.ts b/examples/app-showcase/e2e/detail-shapes.spec.ts index 486a7df9e46..2961a092371 100644 --- a/examples/app-showcase/e2e/detail-shapes.spec.ts +++ b/examples/app-showcase/e2e/detail-shapes.spec.ts @@ -12,7 +12,11 @@ import { test, expect } from '@playwright/test'; * - ungrouped → flat details body, no section cards * - stageField:false → NO `record:path` stepper despite a `status` field * named to bait the heuristic - * - related-heavy → related lists surface as tabs (prominence rule) + * - related-heavy → related lists surface by prominence (ADR-0085), in the + * tabbed tier as record page v3 draws it (objectstack#27055, + * Target 8): a `relatedList: 'primary'` list is a body + * table in the Details tab, the rest sit behind the + * Related tab and self-fetch when it is shown * * Runs against the console the backend serves at /_console (baseURL from * playwright.config.ts; storageState carries the admin session). The @@ -156,25 +160,51 @@ test('stageField:false: status renders as a plain field, no stepper', async ({ p test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => { // The synthesized record page lays itself out by the width of the view it // occupies (objectstack#26678): the tab strip this case opens exists only - // below the two-column floor (`RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` in - // `@object-ui/plugin-detail`). The Desktop Chrome viewport leaves the record - // view right at that floor once the console sidebar takes its share — two - // columns and a "Related" toggle, no tablist — so the case pins a viewport - // that keeps the page in its tabbed tier. - await page.setViewportSize({ width: 1200, height: 800 }); + // below the two-column floor, a record view narrower than + // `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (760 px, `@object-ui/plugin-detail`). + // The case stays in that tabbed tier on purpose: the column tiers are what + // every other page of this smoke draws at the Desktop Chrome viewport, and + // the tab strip is also what the drawer and split pane draw at any width. + // + // The window is derived, not picked. Measured on this harness (a fresh + // browser context, so no stored sidebar cookie): from a 768 px window + // (`MOBILE_BREAKPOINT`, the desktop shell) to 1000 px the console sidebar + // came up as its 48 px icon rail, and the record view was the window minus + // that rail — two columns at 808 px (760 of view), tabs at 800 px (752). + // So the desktop tabbed tier is a window of 768 to 807 px, and an expanded + // sidebar (256 px) only narrows the view further. 780 sits inside it with + // headroom on both sides: 732 px of view against the 760 floor, 12 px above + // the mobile breakpoint. ⛔ Not 1200 px: since objectstack#27473 moved the + // floor to a 760 px view, 1200 draws columns and no tablist. ⛔ Not below + // 768 px: that is the mobile shell, where the off-canvas sidebar gives the + // view the whole window (760 px measured as two columns). + await page.setViewportSize({ width: 780, height: 800 }); const errors = await openRecord(page, recordUrl('showcase_account', contosoId)); // Heuristic stepper (account.status is a select named "status"). // record:path renders a desktop + a mobile variant — assert the visible one. await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); - // Related lists surface as tabs (ADR-0085 prominence rule). + // Related lists surface by prominence (ADR-0085), as record page v3 draws + // them (objectstack#27055, Target 8): Invoices and Projects are + // `relatedList: 'primary'` (CORE relationships), so each is a body table in + // the Details tab, with its rows read at open; the rest of Contoso's lists + // (Contacts and the Field Zoo lookups) sit behind the Related tab. const detailsTab = page.getByRole('tab', { name: /Details/i }); + const relatedTab = page.getByRole('tab', { name: /^Related/ }); await expect(detailsTab).toBeVisible(); - const invoicesTab = page.getByRole('tab', { name: /Invoices/i }); - const projectsTab = page.getByRole('tab', { name: /Projects/i }); - await expect(invoicesTab).toBeVisible(); - await expect(projectsTab).toBeVisible(); + await expect(detailsTab).toHaveAttribute('aria-selected', 'true'); + await expect(relatedTab).toBeVisible(); + await expect(page.getByRole('tab', { name: /Invoices|Projects/i })).toHaveCount(0); + const detailsPanel = page.getByRole('tabpanel', { name: /Details/i }); + const invoicesTable = detailsPanel + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'Invoice Number' }) }); + const projectsTable = detailsPanel + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'Project Name' }) }); + await expect(invoicesTable.getByRole('cell', { name: 'INV-1002', exact: true })).toBeVisible(); + await expect(projectsTable.getByRole('cell', { name: 'Data Platform', exact: true })).toBeVisible(); // Ungrouped shape: the Details body is flat — no fieldGroup section cards. // (Contoso declares no fieldGroups; spot-check a body field label.) @@ -190,13 +220,13 @@ test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', expect(footerText, 'footer dangles "Created by" without an actor').not.toMatch(/Created by/); expect(footerText).toMatch(/Created/); - // Related lists self-fetch lazily when their tab is shown. - await invoicesTab.click(); - const panel = page.getByRole('tabpanel'); - await expect(panel).toBeVisible(); - await page.waitForTimeout(1500); - const panelText = (await panel.innerText().catch(() => '')) || ''; - expect(panelText.trim().length, 'invoices tab rendered no content').toBeGreaterThan(0); + // The non-primary lists self-fetch lazily when their tab is shown: the + // Related tab's Contacts list reads Contoso's seeded contacts. + await relatedTab.click(); + await expect(relatedTab).toHaveAttribute('aria-selected', 'true'); + const relatedPanel = page.getByRole('tabpanel', { name: /^Related/ }); + await expect(relatedPanel).toBeVisible(); + await expect(relatedPanel.getByRole('cell', { name: 'Carl Fox', exact: true })).toBeVisible(); expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]); }); diff --git a/ui/e2e/live/record-history-display.spec.ts b/ui/e2e/live/record-history-display.spec.ts index 024da829667..d3554117577 100644 --- a/ui/e2e/live/record-history-display.spec.ts +++ b/ui/e2e/live/record-history-display.spec.ts @@ -43,11 +43,7 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser /** * The synthesized record page lays itself out by the width of the view it * occupies (objectstack#26678): a column tier at `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` - * (`@object-ui/plugin-detail`) and wider, the tab strip below it. The stream - * is resident in either column tier — the timeline column, or, while the - * reader's timeline switch is off (the default for showcase_account, which is - * not a worked object), the Activity card at the related column's foot — and - * mounted at open, so the spec needs no tab and no switch. + * (`@object-ui/plugin-detail`) and wider, the tab strip below it. * * A 1440 px window is a column tier whatever the console sidebar does: 1184 px * of record view with the sidebar expanded (objectstack#27241's three-column @@ -56,6 +52,19 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser * at 1000×800 the sidebar came up as its icon rail and the page drew two * columns with no tab strip at all, so a spec that waited for a tab there * timed out. + * + * In a column tier the stream is NOT resident by default for this object. The + * reader's Timeline switch (the header's Panels control) defaults to off for + * an object that is not worked (`recordTimelineDefaultReason`: showcase_account + * declares no `stageField`, no lifecycle option `kind` and no `enable.files`), + * and a switched-off timeline folds into the Activity (动态) card at the related + * column's foot — closed, its blocks not mounted until the reader opens it + * (`FoldedTimelineCard`, objectstack#27052). Measured under the nightly harness + * (a built Console on a fresh database, so no stored switch): at 1440×900 the + * page drew three columns, the Panels control read Timeline unpressed, and the + * Activity card was folded, so a spec that waited for the Changes chip at open + * timed out. The spec opens that card the way a reader does, and asserts each + * step: the switch is off, the card starts closed, the click opens it. */ test.use({ viewport: { width: 1440, height: 900 } }); @@ -83,11 +92,22 @@ test('the timeline stream\'s Changes chip shows display values (localized dates, expect(res.ok()).toBeTruthy(); } - // 2) Open the synthesized record page and select the timeline stream's - // Changes chip — the field changes alone (the audit entries, plus the - // activity stream's own `created` row). + // 2) Open the synthesized record page, open the folded Activity card that + // holds the stream (see the viewport note above), and select the + // stream's Changes chip — the field changes alone (the audit entries, + // plus the activity stream's own `created` row). await page.goto(`/apps/showcase_app/showcase_account/record/${id}`); - const stream = page.getByRole('region', { name: /^(Timeline|时间线)$/ }); + const timelineSwitch = page.locator('[data-panel-toggles] [data-panel-toggle="timeline"]'); + // The page-open wait: the same 15 s budget the spec gives its first element. + await expect(timelineSwitch, 'the Timeline switch is off for an object that is not worked').toHaveAttribute('aria-pressed', 'false', { timeout: 15_000 }); + const activityCard = page.getByRole('region', { name: /^(Activity|动态)$/ }); + // The toggle is the card heading's button: once open, the stream inside the + // card has an "Activity" filter chip of its own. + const activityToggle = activityCard.getByRole('heading', { name: /^(Activity|动态)$/ }).getByRole('button'); + await expect(activityToggle, 'the switched-off timeline is folded into a closed Activity card').toHaveAttribute('aria-expanded', 'false'); + await activityToggle.click(); + await expect(activityToggle).toHaveAttribute('aria-expanded', 'true'); + const stream = activityCard.getByRole('region', { name: /^(Timeline|时间线)$/ }); const changesChip = stream.locator('[data-stream-filter="changes"]'); await changesChip.waitFor({ state: 'visible', timeout: 15_000 }); await changesChip.click();