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:
@@ -62,4 +62,17 @@ export default defineConfig({
|
|||||||
use: { ...devices['Desktop Chrome'] },
|
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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts
Normal file
11
apps/app/tests/playwright-ct/visual/prototype-smoke.spec.ts
Normal 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 })
|
||||||
|
})
|
||||||
203
apps/app/tests/playwright-ct/visual/prototype.spec.ts
Normal file
203
apps/app/tests/playwright-ct/visual/prototype.spec.ts
Normal 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.
|
||||||
|
})
|
||||||
|
})
|
||||||
72
apps/app/tests/playwright-ct/visual/static-server.mjs
Normal file
72
apps/app/tests/playwright-ct/visual/static-server.mjs
Normal 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}`)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user