From f6509d938b63864971df4b363de1db674d00b759 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 15:04:51 +0200 Subject: [PATCH] test(visual): prototype static-server fixture + 5 composite baselines (TEST-VISUAL-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B3 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add tests/playwright-ct/visual/static-server.mjs: 60-line Node http server that serves the canonical prototype directory. No new dependency added (vs. http-server / serve packages). - Wire static server into playwright-ct.config.ts via webServer; tests navigate to http://127.0.0.1:5179/crewli-timetable.html. - Add tests/playwright-ct/visual/prototype-smoke.spec.ts to verify the prototype loads in CT runner. - Add tests/playwright-ct/visual/prototype.spec.ts with 5 @visual composite baselines: canvas-friday.png — all status colors, b2b indicators, multi-lane stacking canvas-saturday.png — conflict ring + capacity warnings stage-row-multilane.png — first row in isolation wachtrij-populated.png — sidebar list with parked + pending popover.png — block-click popover layout 9 additional surfaces from RFC §A.3's enumerated list are documented as test.skip() with reasons (cancelled status absent from prototype data, isolated-block locators would lock to artist names, drag-mode flaky under simulated pointer events, empty Wachtrij/empty day not reachable from canonical seed). All deferred to F4 component-level Vue baselines that will use stable data-test-id attributes. - Baselines stored at tests/playwright-ct/__screenshots__/visual/ prototype.spec.ts/*.png; tracked via Git LFS (.gitattributes). Composite-over-isolated rationale: the prototype's DOM exposes status only via inline style.background, no data-* attributes. Isolated-block baselines would require artist-name locators that silently rot if prototype data changes. Composite captures yield the same visual vocabulary in fewer, more stable images. dev-docs/ARCH-TESTING.md (B5) documents this strategy and the F4 transition plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/playwright-ct.config.ts | 13 ++ .../prototype.spec.ts/canvas-friday.png | 3 + .../prototype.spec.ts/canvas-saturday.png | 3 + .../visual/prototype.spec.ts/popover.png | 3 + .../prototype.spec.ts/stage-row-multilane.png | 3 + .../prototype.spec.ts/wachtrij-populated.png | 3 + .../visual/prototype-smoke.spec.ts | 11 + .../playwright-ct/visual/prototype.spec.ts | 203 ++++++++++++++++++ .../playwright-ct/visual/static-server.mjs | 72 +++++++ 9 files changed, 314 insertions(+) create mode 100644 apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png create mode 100644 apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png create mode 100644 apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png create mode 100644 apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png create mode 100644 apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png create mode 100644 apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts create mode 100644 apps/app/tests/playwright-ct/visual/prototype.spec.ts create mode 100644 apps/app/tests/playwright-ct/visual/static-server.mjs diff --git a/apps/app/playwright-ct.config.ts b/apps/app/playwright-ct.config.ts index 2be125e4..a0e1b52a 100644 --- a/apps/app/playwright-ct.config.ts +++ b/apps/app/playwright-ct.config.ts @@ -62,4 +62,17 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], + + // Auto-start the prototype static server for visual baselines. + // Tests under tests/playwright-ct/visual/ navigate to this URL via + // page.goto() rather than calling mount(); they coexist with normal + // CT tests in the same runner. + webServer: { + command: 'node tests/playwright-ct/visual/static-server.mjs', + url: `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + stdout: 'ignore', + stderr: 'pipe', + }, }) diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png new file mode 100644 index 00000000..0eae1e50 --- /dev/null +++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-friday.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:acd74364b466e490092925546dc3a065cfdf6dc6898a68d3262b8e3b2e23e969 +size 94506 diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png new file mode 100644 index 00000000..01480bd0 --- /dev/null +++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/canvas-saturday.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:404193ea0b6848b641900d61ec29d6b5ff70571d31e216d8fa9e4c0ebab517c3 +size 84425 diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png new file mode 100644 index 00000000..65ccb89d --- /dev/null +++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/popover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a280af3450f2a9bd1e7d9b8b30153549e347813e1b16763417997229f0729875 +size 22473 diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png new file mode 100644 index 00000000..dc66a53c --- /dev/null +++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/stage-row-multilane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67647f68e96447bde097f7fef52dc8ab1ff5a6de44eaff6b275ecf899ca6d48f +size 22160 diff --git a/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png new file mode 100644 index 00000000..d6ec9e5a --- /dev/null +++ b/apps/app/tests/playwright-ct/__screenshots__/visual/prototype.spec.ts/wachtrij-populated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c4836eada0f817464928aca1896c904d5ebfea2c2c1257c6f695de4c36e85dd +size 41833 diff --git a/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts b/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts new file mode 100644 index 00000000..2b7896c9 --- /dev/null +++ b/apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@playwright/experimental-ct-vue' + +const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html` + +test('prototype loads and renders', async ({ page }) => { + await page.goto(PROTOTYPE_URL) + + // Wait for the React app to render. The first stage row appears + // once data + babel-transformed JSX are loaded. + await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 }) +}) diff --git a/apps/app/tests/playwright-ct/visual/prototype.spec.ts b/apps/app/tests/playwright-ct/visual/prototype.spec.ts new file mode 100644 index 00000000..7bd6c6f6 --- /dev/null +++ b/apps/app/tests/playwright-ct/visual/prototype.spec.ts @@ -0,0 +1,203 @@ +import { expect, test } from '@playwright/experimental-ct-vue' + +// ============================================================================= +// VISUAL BASELINES — Artist Management surfaces (RFC §A.3 baseline scope) +// ============================================================================= +// +// SOURCE OF TRUTH: the Crewli prototype HTML at +// resources/Crewli - Artist Timetable Management/crewli-timetable.html +// This is the canonical visual reference Artist Management is measured +// against during F4. When the prototype changes, baselines update with +// `pnpm test:visual:update` and the diff PNG is reviewed in the PR. +// +// Tagged @visual so `pnpm test:visual` runs only this suite. +// +// Baseline strategy — composite over isolated: +// -------------------------------------------- +// The prototype's DOM does not expose status, perf-id, or stage-id as +// data-* attributes. Block status is encoded in inline `style.background` +// only. Locating an individual block by status would require either +// pixel-color sampling (brittle) or hardcoding artist names (locks the +// test to specific data values, which would silently rot if the +// prototype's seed data changes). +// +// Instead we capture COMPOSITE surfaces that contain the full visual +// vocabulary side-by-side: +// - Full Vrijdag canvas → all status colors, b2b, multi-lane +// - Full Zaterdag canvas → conflict ring, capacity warning +// - Wachtrij sidebar → list rendering, status badges, counts +// - Popover (clicked from canvas) → popover layout +// One screenshot covers many "surfaces" the prompt's §A.3 list +// enumerates separately. F4 component-level Vue tests will provide +// per-state isolated baselines using the live SPA's data-test-id +// attributes (added during component migration). +// +// Surfaces NOT captured here are documented as test.skip() at the +// bottom with the reason and what would be required to enable them. +// ============================================================================= + +const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html` + +async function navigateAndStabilize(page: import('@playwright/test').Page) { + await page.goto(PROTOTYPE_URL) + await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 }) + + // Hide elements that vary visually between machines or are dev-only. + await page.addStyleTag({ + content: ` + *:focus { outline: none !important; } + .cw-tweaks-panel, .cw-tweak-btn { visibility: hidden !important; } + `, + }) + await page.evaluate(() => document.fonts.ready) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))) +} + +test.describe('@visual Artist Management — prototype baselines', () => { + test.use({ viewport: { width: 1440, height: 900 } }) + + // --------------------------------------------------------------------------- + // CANVAS — full-page baselines per day + // + // Vrijdag (d_fr) covers status colors (option, requested, confirmed, + // contracted, concept), B2B indicators (auto-derived for adjacent + // perfs on hardstyle: p_1→p_2→p_3), and multi-lane stacking + // (p_4 / p_4b / p_4c overlap). + // + // Zaterdag (d_sa) covers conflict ring (p_18 vs p_19 overlap on + // s_urban) and capacity warning (p_14 draw 4800/cap 5000, p_15 + // draw 5200/cap 5000 on s_hollandse). + // --------------------------------------------------------------------------- + test('canvas — Vrijdag (statuses + b2b + multi-lane)', async ({ page }) => { + await navigateAndStabilize(page) + await expect(page).toHaveScreenshot('canvas-friday.png', { fullPage: true }) + }) + + test('canvas — Zaterdag (conflict + capacity warning)', async ({ page }) => { + await navigateAndStabilize(page) + + // Day tab uses role="tab", not "button". + await page.getByRole('tab', { name: /Zaterdag/ }).click() + await page.waitForTimeout(200) + await expect(page).toHaveScreenshot('canvas-saturday.png', { fullPage: true }) + }) + + // --------------------------------------------------------------------------- + // STAGE ROW — first row in isolation, captures multi-lane stacking + // detail. The prototype renders rows as .cw-tt-stage (label column) + // + a corresponding lane-band in the canvas. We capture the canvas + // element which spans 1440×row-height. + // --------------------------------------------------------------------------- + test('stage row — first row (Hardstyle, multi-lane on Vrijdag)', async ({ page }) => { + await navigateAndStabilize(page) + + // The first .cw-tt-stage is hardstyle on Vrijdag. We capture the + // full row width by screenshotting the .cw-tt container's first + // visible row using a clip rectangle derived from the element. + const firstStage = page.locator('.cw-tt-stage').first() + + await expect(firstStage).toBeVisible() + + const stageBox = await firstStage.boundingBox() + if (!stageBox) + throw new Error('Could not measure first stage row') + + await expect(page).toHaveScreenshot('stage-row-multilane.png', { + clip: { x: 0, y: stageBox.y, width: 1440, height: stageBox.height }, + }) + }) + + // --------------------------------------------------------------------------- + // WACHTRIJ — populated state (default seed data has parked + pending) + // --------------------------------------------------------------------------- + test('wachtrij — populated (default seed)', async ({ page }) => { + await navigateAndStabilize(page) + + const queue = page.locator('aside.cw-parking').first() + + await expect(queue).toBeVisible() + await expect(queue).toHaveScreenshot('wachtrij-populated.png') + }) + + // --------------------------------------------------------------------------- + // POPOVER — opens on block click + // --------------------------------------------------------------------------- + test('popover — opens on first-block click', async ({ page }) => { + await navigateAndStabilize(page) + + // Click any visible block; the popover renders into a portal + // sibling of the block. We pick .cw-block first so we don't depend + // on artist names. + await page.locator('.cw-block').first().click() + + const popover = page.locator('.cw-popover').first() + + await expect(popover).toBeVisible({ timeout: 5_000 }) + await expect(popover).toHaveScreenshot('popover.png') + }) + + // --------------------------------------------------------------------------- + // SKIPPED — surfaces not capturable from the canonical prototype. + // Each documents the gap so F4 component-level baselines pick them up. + // --------------------------------------------------------------------------- + test.skip('block — cancelled status (isolated)', async () => { + // Prototype data has no cancelled performance (data.js shows only + // concept|requested|option|confirmed|contracted appear). Status + // filter at timetable.jsx:227 also defaults cancelled OFF. To + // capture this baseline we'd modify prototype data — that would + // violate "we don't fake the prototype" (sprint prompt). Defer to + // F4 where the live SPA renders cancelled blocks against real + // data and we can baseline a dedicated cancelled fixture. + }) + + test.skip('block — B2B indicator (isolated)', async () => { + // Covered by canvas-friday.png composite (p_1→p_2→p_3 are + // perfectly back-to-back on hardstyle). An isolated block-level + // baseline would require artist-name-based locators that lock + // the test to specific data values. Defer to F4 where Vue + // components expose data-test-id="performance-block". + }) + + test.skip('block — capacity warning (isolated)', async () => { + // Covered by canvas-saturday.png composite. Same locator + // brittleness rationale as B2B above. + }) + + test.skip('block — conflict ring (isolated)', async () => { + // Covered by canvas-saturday.png composite. + }) + + test.skip('add-performance dialog — drag mode', async () => { + // Drag-mode requires a real mousedown→mousemove→mouseup sequence + // on the canvas. The prototype's drag detection uses raw mouse + // event math that's flaky under Playwright's page.mouse simulated + // events. F4 component test will use a stable drag-handle API on + // the live Vue component. + }) + + test.skip('add-performance dialog — button mode', async () => { + // The prototype does not expose a "+ Voorstelling" button at + // the canvas level — performances are only added via drag-from- + // wachtrij or by clicking an empty time slot. F4 component + // tests will baseline the live SPA's explicit "Voorstelling + // toevoegen" button. + }) + + test.skip('wachtrij — empty state', async () => { + // Prototype seed data ships with PARKED + PENDING populated; + // no UI flow drains the queue to empty. Defer to F4 component + // test against an empty-state fixture. + }) + + test.skip('wachtrij — grouped by status with counts', async () => { + // Grouping toggle exists (cw-pq-msel-btn) but its visual delta + // vs ungrouped is small and the toggle state is opaque to a + // post-render observer. Defer to F4 where the Vue component + // exposes state via data-test-state="grouped"|"ungrouped". + }) + + test.skip('canvas — empty day', async () => { + // Prototype only has d_fr and d_sa, both populated. No empty-day + // baseline available from canonical data. Defer to F4. + }) +}) diff --git a/apps/app/tests/playwright-ct/visual/static-server.mjs b/apps/app/tests/playwright-ct/visual/static-server.mjs new file mode 100644 index 00000000..88234aef --- /dev/null +++ b/apps/app/tests/playwright-ct/visual/static-server.mjs @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ +import { createReadStream, statSync } from 'node:fs' +import http from 'node:http' +import path from 'node:path' + +// Tiny static-file server that serves the canonical Crewli prototype +// HTML so Playwright visual baselines can render it in real Chromium. +// +// Why not http-server / serve / similar? +// -------------------------------------- +// They'd add ~5 MB and another supply-chain hop for a 30-line problem. +// The prototype directory has 9 files; we serve them with mime types +// for .html, .css, .js, .jsx (text/babel handles via the HTML loader). +// Node's built-in http + fs is sufficient. +// +// Started by playwright-ct.config.ts via webServer config. + +const ROOT = path.resolve( + process.cwd(), + '..', + '..', + 'resources', + 'Crewli - Artist Timetable Management', +) +const PORT = Number(process.env.PROTOTYPE_PORT ?? 5179) + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.jsx': 'text/babel; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +} + +const server = http.createServer((req, res) => { + // Strip query / hash before path resolution. + const urlPath = (req.url ?? '/').split('?')[0].split('#')[0] + const safe = path.normalize(urlPath).replace(/^(\.\.[\\/])+/, '') + const filePath = path.join(ROOT, safe === '/' ? 'crewli-timetable.html' : safe) + + // Reject path traversal. + if (!filePath.startsWith(ROOT)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + try { + const stat = statSync(filePath) + if (stat.isDirectory()) { + res.statusCode = 404 + res.end('Not found') + return + } + const ext = path.extname(filePath).toLowerCase() + res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream') + res.setHeader('Cache-Control', 'no-store') + createReadStream(filePath).pipe(res) + } + catch { + res.statusCode = 404 + res.end(`Not found: ${urlPath}`) + } +}) + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[prototype-server] http://127.0.0.1:${PORT}/ -> ${ROOT}`) +})