|
| 1 | +import { test, expect } from '@playwright/test'; |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0085 detail-shape regression (#2548) — permanent form of the one-off |
| 5 | + * real-backend browser verification that closed objectui#2546 (PR4: legacy |
| 6 | + * monolith detail renderer + `renderViaSchema` kill-switch removed). |
| 7 | + * |
| 8 | + * Four shapes, one invariant each, all through the single SchemaRenderer / |
| 9 | + * `buildDefaultPageSchema` path the console now has: |
| 10 | + * - grouped → fieldGroup sections render; `collapse: 'collapsed'` |
| 11 | + * actually collapses (Money hides `budget` until opened) |
| 12 | + * - ungrouped → flat details body, no section cards |
| 13 | + * - stageField:false → NO `record:path` stepper despite a `status` field |
| 14 | + * named to bait the heuristic |
| 15 | + * - related-heavy → related lists surface as tabs (prominence rule) |
| 16 | + * |
| 17 | + * Runs against the console the backend serves at /_console (baseURL from |
| 18 | + * playwright.config.ts; storageState carries the admin session). The |
| 19 | + * semantic-zoo fixtures are objects but not seeded records — each run |
| 20 | + * creates its own rows via the REST API. |
| 21 | + * |
| 22 | + * NOTE: assertions are scoped to what the *pinned* console build supports |
| 23 | + * (.objectui-sha). Group icon/description chrome ships with a later objectui |
| 24 | + * bump — extend the grouped case then. |
| 25 | + */ |
| 26 | + |
| 27 | +const APP = process.env.SHOWCASE_APP || 'com.example.showcase'; |
| 28 | +const API = process.env.SMOKE_API_URL || 'http://localhost:3000'; |
| 29 | +const recordUrl = (object: string, id: string) => |
| 30 | + `/_console/apps/${APP}/${object}/record/${encodeURIComponent(id)}`; |
| 31 | + |
| 32 | +const PATH_STEPPER = '[aria-label="Record path"]'; |
| 33 | + |
| 34 | +let zooId = ''; |
| 35 | +let zooLegacyId = ''; |
| 36 | +let contosoId = ''; |
| 37 | + |
| 38 | +test.beforeAll(async ({ request }) => { |
| 39 | + const createRecord = async (object: string, data: Record<string, unknown>) => { |
| 40 | + const res = await request.post(`${API}/api/v1/data/${object}`, { data }); |
| 41 | + expect(res.ok(), `create ${object} failed: ${res.status()} ${await res.text()}`).toBeTruthy(); |
| 42 | + const body = (await res.json()) as any; |
| 43 | + const id = body.id ?? body.record?.id ?? body.data?.id; |
| 44 | + expect(id, `no id returned creating ${object}`).toBeTruthy(); |
| 45 | + return String(id); |
| 46 | + }; |
| 47 | + |
| 48 | + zooId = await createRecord('showcase_semantic_zoo', { |
| 49 | + name: 'E2E Zoo Grouped', |
| 50 | + status: 'active', |
| 51 | + code: 'ZG-1', |
| 52 | + amount: 4200, |
| 53 | + budget: 100000, |
| 54 | + notes: 'detail-shapes e2e fixture', |
| 55 | + }); |
| 56 | + zooLegacyId = await createRecord('showcase_semantic_zoo_legacy', { |
| 57 | + name: 'E2E Zoo Legacy', |
| 58 | + status: 'green', |
| 59 | + amount: 99, |
| 60 | + }); |
| 61 | + |
| 62 | + // Contoso is seeded with 2 contacts / 2 invoices / 2 projects — the |
| 63 | + // related-heavy shape. Resolve its id instead of assuming one. |
| 64 | + const res = await request.get(`${API}/api/v1/data/showcase_account?%24top=50`); |
| 65 | + expect(res.ok(), `account list failed: ${res.status()}`).toBeTruthy(); |
| 66 | + const rows: any[] = ((await res.json()) as any).records ?? []; |
| 67 | + const contoso = rows.find((r) => r?.name === 'Contoso'); |
| 68 | + expect(contoso, 'seeded account "Contoso" not found').toBeTruthy(); |
| 69 | + contosoId = String(contoso.id); |
| 70 | +}); |
| 71 | + |
| 72 | +async function openRecord(page: import('@playwright/test').Page, url: string) { |
| 73 | + const pageErrors: string[] = []; |
| 74 | + page.on('pageerror', (e) => pageErrors.push(e.message)); |
| 75 | + await page.goto(url, { waitUntil: 'domcontentloaded' }); |
| 76 | + await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 }); |
| 77 | + await page.waitForTimeout(1500); |
| 78 | + return pageErrors; |
| 79 | +} |
| 80 | + |
| 81 | +test('grouped: fieldGroup sections render and Money starts collapsed', async ({ page }) => { |
| 82 | + const errors = await openRecord(page, recordUrl('showcase_semantic_zoo', zooId)); |
| 83 | + |
| 84 | + // Stepper from stageField: 'status'. |
| 85 | + // record:path renders a desktop + a mobile variant — assert the visible one. |
| 86 | + await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); |
| 87 | + |
| 88 | + // Declared groups render as titled sections. Their highlighted members |
| 89 | + // (status/amount) are hoisted to the strip; the non-highlighted members |
| 90 | + // (code/budget) keep the groups visible in the body. |
| 91 | + await expect(page.getByText('Basics', { exact: true })).toBeVisible(); |
| 92 | + await expect(page.getByText('Money', { exact: true })).toBeVisible(); |
| 93 | + await expect(page.getByText('ZG-1')).toBeVisible(); |
| 94 | + |
| 95 | + // collapse: 'collapsed' — Budget stays hidden until the header is opened. |
| 96 | + await expect(page.getByText('Budget', { exact: true })).toHaveCount(0); |
| 97 | + await page.getByText('Money', { exact: true }).click(); |
| 98 | + await expect(page.getByText('Budget', { exact: true })).toBeVisible(); |
| 99 | + |
| 100 | + expect(errors, 'uncaught page errors on grouped detail').toEqual([]); |
| 101 | +}); |
| 102 | + |
| 103 | +test('stageField:false: status renders as a plain field, no stepper', async ({ page }) => { |
| 104 | + const errors = await openRecord( |
| 105 | + page, |
| 106 | + recordUrl('showcase_semantic_zoo_legacy', zooLegacyId), |
| 107 | + ); |
| 108 | + |
| 109 | + // The whole point of `stageField: false`: the status-named select must NOT |
| 110 | + // become a chevron path. (objectui#2168 pinned strict-false handling.) |
| 111 | + await expect(page.locator(PATH_STEPPER)).toHaveCount(0); |
| 112 | + // …but the field itself still renders (as a value, not a lifecycle). |
| 113 | + await expect(page.getByText('Green').first()).toBeVisible(); |
| 114 | + |
| 115 | + expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]); |
| 116 | +}); |
| 117 | + |
| 118 | +test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => { |
| 119 | + const errors = await openRecord(page, recordUrl('showcase_account', contosoId)); |
| 120 | + |
| 121 | + // Heuristic stepper (account.status is a select named "status"). |
| 122 | + // record:path renders a desktop + a mobile variant — assert the visible one. |
| 123 | + await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); |
| 124 | + |
| 125 | + // Related lists surface as tabs (ADR-0085 prominence rule). |
| 126 | + const detailsTab = page.getByRole('tab', { name: /Details/i }); |
| 127 | + await expect(detailsTab).toBeVisible(); |
| 128 | + const invoicesTab = page.getByRole('tab', { name: /Invoices/i }); |
| 129 | + const projectsTab = page.getByRole('tab', { name: /Projects/i }); |
| 130 | + await expect(invoicesTab).toBeVisible(); |
| 131 | + await expect(projectsTab).toBeVisible(); |
| 132 | + |
| 133 | + // Ungrouped shape: the Details body is flat — no fieldGroup section cards. |
| 134 | + // (Contoso declares no fieldGroups; spot-check a body field label.) |
| 135 | + await expect(page.getByText('Basics', { exact: true })).toHaveCount(0); |
| 136 | + |
| 137 | + // Related lists self-fetch lazily when their tab is shown. |
| 138 | + await invoicesTab.click(); |
| 139 | + const panel = page.getByRole('tabpanel'); |
| 140 | + await expect(panel).toBeVisible(); |
| 141 | + await page.waitForTimeout(1500); |
| 142 | + const panelText = (await panel.innerText().catch(() => '')) || ''; |
| 143 | + expect(panelText.trim().length, 'invoices tab rendered no content').toBeGreaterThan(0); |
| 144 | + |
| 145 | + expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]); |
| 146 | +}); |
0 commit comments