test(visual): prototype static-server fixture + 5 composite baselines (TEST-VISUAL-001)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:04:51 +02:00
parent 82af11754a
commit f6509d938b
9 changed files with 314 additions and 0 deletions

View File

@@ -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',
},
})

View File

@@ -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 })
})

View File

@@ -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.
})
})

View File

@@ -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}`)
})