Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 49 additions & 19 deletions examples/app-showcase/e2e/detail-shapes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)
Expand All @@ -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([]);
});
38 changes: 29 additions & 9 deletions ui/e2e/live/record-history-display.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } });

Expand Down Expand Up @@ -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();
Expand Down
Loading