From b8d18e63aff8f97494ae5bec2a8ac7c964ef45ea Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 14:53:57 +0200 Subject: [PATCH 1/9] chore(test-infra): install Playwright + axe-core; configure CT and e2e runners; enable Git LFS for screenshots B1 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add @playwright/test, @playwright/experimental-ct-vue, @axe-core/playwright as dev deps in apps/app - Add @vue/compiler-dom (transitively required by ct-vue's Vite build pipeline; not auto-resolved on Vite 7) - Install Chromium via `playwright install chromium` (host cache only, not committed) - Configure Git LFS clean/smudge filters globally; track apps/app/tests/playwright-{ct,e2e}/__screenshots__/**/*.png - Integrate `git lfs pre-push` into lefthook.yml since LFS's per-repo hook would conflict with the existing sync-staleness hook - Add playwright/index.html + playwright/index.ts hook file with the full provider stack (Vuetify [TEMPORARY: replaced in F3 by PrimeVue], Pinia, TanStack Vue Query, memory-history Router with no auth guards) - Add playwright.config.ts (e2e, Chromium-only, baseURL :5173, auto- starts `pnpm dev` via webServer) - Add playwright-ct.config.ts (component testing, Linux-Chromium-only baselines, maxDiffPixelRatio 0.001, snapshot path template, ssr.noExternal: ['vuetify'] mirroring vitest.config.ts) - Add scripts: test:component, test:e2e, test:visual, test:visual:update - Add smoke test proving Chromium boots in the CT runner - Update .gitignore for Playwright runtime artifacts (test-results/, playwright-report/, blob-report/, playwright/.cache/) Vitest's existing 402 tests still pass unchanged. DoD-17 / DoD-19 CI integration deferred to TEST-INFRA-002 per Amendment A-1 scope cut (no CI exists in this repo today). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 2 + .gitignore | 8 + apps/app/package.json | 10 +- apps/app/playwright-ct.config.ts | 65 ++++++ apps/app/playwright.config.ts | 41 ++++ apps/app/playwright/index.html | 12 + apps/app/playwright/index.ts | 120 ++++++++++ apps/app/pnpm-lock.yaml | 244 +++++++++++++++++++-- apps/app/tests/playwright-ct/smoke.spec.ts | 8 + lefthook.yml | 7 + 10 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 .gitattributes create mode 100644 apps/app/playwright-ct.config.ts create mode 100644 apps/app/playwright.config.ts create mode 100644 apps/app/playwright/index.html create mode 100644 apps/app/playwright/index.ts create mode 100644 apps/app/tests/playwright-ct/smoke.spec.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..05d9b352 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +apps/app/tests/playwright-ct/**/__screenshots__/**/*.png filter=lfs diff=lfs merge=lfs -text +apps/app/tests/playwright-e2e/**/__screenshots__/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 0f4834fe..6a973024 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,14 @@ storage/framework/views/* .phpunit.result.cache coverage/ +# Playwright runtime artifacts (test-results, blob-report, html-report, +# .cache build dir, playwright traces). __screenshots__/ is committed +# (via Git LFS, see .gitattributes). +apps/app/test-results/ +apps/app/playwright-report/ +apps/app/blob-report/ +apps/app/playwright/.cache/ + # Misc *.pem .cache/ diff --git a/apps/app/package.json b/apps/app/package.json index dcef67f4..fa6d6dbe 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,7 +14,11 @@ "msw:init": "msw init public/ --save", "postinstall": "npm run build:icons && npm run msw:init", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:component": "playwright test --config=playwright-ct.config.ts", + "test:e2e": "playwright test --config=playwright.config.ts", + "test:visual": "playwright test --config=playwright-ct.config.ts --grep @visual", + "test:visual:update": "playwright test --config=playwright-ct.config.ts --grep @visual --update-snapshots" }, "dependencies": { "@casl/ability": "6.7.3", @@ -67,6 +71,7 @@ "@antfu/eslint-config-ts": "0.43.1", "@antfu/eslint-config-vue": "0.43.1", "@antfu/utils": "0.7.10", + "@axe-core/playwright": "^4.11.3", "@fullcalendar/core": "6.1.19", "@fullcalendar/daygrid": "6.1.19", "@fullcalendar/interaction": "6.1.19", @@ -82,6 +87,8 @@ "@iconify/vue": "4.1.2", "@intlify/unplugin-vue-i18n": "11.0.1", "@pinia/testing": "^1.0.3", + "@playwright/experimental-ct-vue": "^1.59.1", + "@playwright/test": "^1.59.1", "@stylistic/eslint-plugin-js": "0.0.4", "@stylistic/eslint-plugin-ts": "0.0.4", "@stylistic/stylelint-config": "1.0.1", @@ -101,6 +108,7 @@ "@typescript-eslint/parser": "7.18.0", "@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue-jsx": "5.1.1", + "@vue/compiler-dom": "^3.5.34", "@vue/test-utils": "^2.4.9", "axe-core": "^4.11.4", "baseline-browser-mapping": "^2.10.16", diff --git a/apps/app/playwright-ct.config.ts b/apps/app/playwright-ct.config.ts new file mode 100644 index 00000000..2be125e4 --- /dev/null +++ b/apps/app/playwright-ct.config.ts @@ -0,0 +1,65 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/experimental-ct-vue' +import vue from '@vitejs/plugin-vue' + +// Component-test config — mounts individual components in a real +// Chromium via Playwright Component Testing. Used by: +// pnpm test:component (all CT tests, baselines verified) +// pnpm test:visual (subset tagged @visual) +// pnpm test:visual:update (regenerate visual baselines) +// +// E2E (real backend + real frontend) lives in playwright.config.ts. + +const sharedAliases = { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@core': fileURLToPath(new URL('./src/@core', import.meta.url)), + '@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)), + '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)), + '@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)), +} + +export default defineConfig({ + testDir: './tests/playwright-ct', + testMatch: /.*\.spec\.ts$/, + snapshotDir: './tests/playwright-ct/__screenshots__', + snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + + use: { + trace: 'off', + viewport: { width: 1440, height: 900 }, + ctPort: 3100, + ctViteConfig: { + plugins: [vue()], + resolve: { alias: sharedAliases }, + + // Vuetify ships ESM that Vite needs to inline-process; matches + // the convention from vitest.config.ts's component project. + ssr: { noExternal: ['vuetify'] }, + }, + }, + + // Linux-Chromium-only baselines per RFC §A.5 — Firefox/WebKit and + // mobile/responsive viewports deferred to a later sprint. Running + // baselines on macOS (dev) vs Linux (CI) will produce a 1-2px + // anti-alias diff; pixel tolerance below absorbs that, but the + // canonical baseline IS the dev machine for now (no CI yet — see + // BACKLOG TEST-INFRA-002). + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.001, + threshold: 0.2, + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts new file mode 100644 index 00000000..8b6dfb26 --- /dev/null +++ b/apps/app/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' + +// E2E config — drives a real Vite dev server + a real Laravel test +// server. Used by `pnpm test:e2e`. Component tests live in +// playwright-ct.config.ts (different runner). + +export default defineConfig({ + testDir: './tests/playwright-e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: process.env.CI ? 'github' : 'list', + + use: { + baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + trace: 'off', + video: 'off', + screenshot: 'off', + viewport: { width: 1440, height: 900 }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Auto-start the SPA dev server. Laravel's test server is started + // by the per-test fixture in tests/playwright-e2e/fixtures/laravel.ts + // because its lifecycle requires per-run seed control. + webServer: { + command: 'pnpm dev', + url: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'ignore', + stderr: 'pipe', + }, +}) diff --git a/apps/app/playwright/index.html b/apps/app/playwright/index.html new file mode 100644 index 00000000..fd8b764d --- /dev/null +++ b/apps/app/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Playwright CT — Crewli + + +
+ + + diff --git a/apps/app/playwright/index.ts b/apps/app/playwright/index.ts new file mode 100644 index 00000000..3a85b0f1 --- /dev/null +++ b/apps/app/playwright/index.ts @@ -0,0 +1,120 @@ +import { beforeMount } from '@playwright/experimental-ct-vue/hooks' +import { createPinia, setActivePinia } from 'pinia' +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { type RouteRecordRaw, createMemoryHistory, createRouter } from 'vue-router' +import { type ThemeDefinition, createVuetify } from 'vuetify' + +// vuetify/components namespace import: required to register the full +// component set on a freshly-created Vuetify instance per test, mirroring +// tests/utils/mountWithVuexy.ts. Test infra only. +import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports +import * as directives from 'vuetify/directives' + +// Plain-CSS token sheet — JSDOM evaluates :root custom properties from +// this import so getComputedStyle(el).getPropertyValue('--tt-status-…') +// resolves during component tests. Path resolved by the alias map in +// playwright-ct.config.ts. +import '@/styles/tokens/_timetable.css' + +// ============================================================================= +// HOOKS CONFIG (per-test, opt-in) +// ============================================================================= +// +// Tests pass `hooksConfig` to mount() to override defaults. Shape: +// { +// initialRoute?: string, +// initialQuery?: Record, +// routes?: RouteRecordRaw[], +// piniaInitialState?: Record>, +// // injected by mountWithProviders.ts wrapper: +// notificationMockKey?: string, +// } +// +// Defaults below render every component with the full Vuexy/Vuetify +// stack. F3 (PrimeVue foundation) replaces the Vuetify plugin line +// here with PrimeVue and updates the sanity test — that is a ~2-hour +// swap, not a rewrite. Vuetify is INTENTIONAL TEMPORARY STATE in this +// file; do not abstract behind a "UI framework provider" indirection +// because the abstraction would itself need to be removed in F3. +// See dev-docs/ARCH-TESTING.md §6 for the migration timeline. +// ============================================================================= + +export interface HooksConfig { + initialRoute?: string + initialQuery?: Record + routes?: RouteRecordRaw[] + piniaInitialState?: Record> +} + +const defaultTheme: ThemeDefinition = { + dark: false, + colors: { + primary: '#1f7ad1', + error: '#d63d4b', + success: '#2fa66a', + warning: '#e0992c', + info: '#1f7ad1', + }, +} + +beforeMount(async ({ app, hooksConfig }) => { + // ---- Vuetify (TEMPORARY: replaced by PrimeVue in F3) ----------------- + const vuetify = createVuetify({ + components, + directives, + theme: { defaultTheme: 'crewliLight', themes: { crewliLight: defaultTheme } }, + }) + + app.use(vuetify) + + // ---- Pinia ---------------------------------------------------------- + // Fresh instance per test. We do NOT use @pinia/testing's + // createTestingPinia here because it depends on Vitest's `vi.fn`, + // which doesn't exist in Playwright's Node runtime. Tests that need + // to assert on store actions should snapshot store state via + // page.evaluate() against window.__pinia (exposed below). + const pinia = createPinia() + + app.use(pinia) + setActivePinia(pinia) + + if (hooksConfig?.piniaInitialState) { + // Hydrate store state directly. Stores are created lazily on first + // use(); pre-hydration via pinia.state.value is safe. + pinia.state.value = { + ...pinia.state.value, + ...hooksConfig.piniaInitialState, + } + } + + // Expose pinia on window for cross-frame state assertions. + ;(globalThis as { __pinia?: typeof pinia }).__pinia = pinia + + // ---- TanStack Vue Query --------------------------------------------- + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }) + + app.use(VueQueryPlugin, { queryClient }) + + // ---- Router (memory history; no auth guards) ------------------------ + const routes: RouteRecordRaw[] = hooksConfig?.routes ?? [ + { path: '/', component: { template: '
' } }, + { path: '/:pathMatch(.*)*', component: { template: '
' } }, + ] + + const router = createRouter({ history: createMemoryHistory(), routes }) + + app.use(router) + + if (hooksConfig?.initialRoute) { + await router.push({ + path: hooksConfig.initialRoute, + query: hooksConfig.initialQuery, + }) + } + await router.isReady() +}) diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 1761002e..63e3066f 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: '@antfu/utils': specifier: 0.7.10 version: 0.7.10 + '@axe-core/playwright': + specifier: ^4.11.3 + version: 4.11.3(playwright-core@1.59.1) '@fullcalendar/core': specifier: 6.1.19 version: 6.1.19 @@ -198,10 +201,16 @@ importers: version: 4.1.2(vue@3.5.22(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: 11.0.1 - version: 11.0.1(@vue/compiler-dom@3.5.22)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 11.0.1(@vue/compiler-dom@3.5.34)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@pinia/testing': specifier: ^1.0.3 version: 1.0.3(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))) + '@playwright/experimental-ct-vue': + specifier: ^1.59.1 + version: 1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1) + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@stylistic/eslint-plugin-js': specifier: 0.0.4 version: 0.0.4 @@ -216,7 +225,7 @@ importers: version: 2.1.3(stylelint@16.8.0(typescript@5.9.3)) '@testing-library/vue': specifier: ^8.1.0 - version: 8.1.0(@vue/compiler-dom@3.5.22)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 8.1.0(@vue/compiler-dom@3.5.34)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@tiptap/extension-character-count': specifier: ^2.27.1 version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) @@ -259,9 +268,12 @@ importers: '@vitejs/plugin-vue-jsx': specifier: 5.1.1 version: 5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3)) + '@vue/compiler-dom': + specifier: ^3.5.34 + version: 3.5.34 '@vue/test-utils': specifier: ^2.4.9 - version: 2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) axe-core: specifier: ^4.11.4 version: 4.11.4 @@ -387,7 +399,7 @@ importers: version: 0.18.6(@vueuse/core@10.11.1(vue@3.5.22(typescript@5.9.3)))(rollup@4.52.5) unplugin-vue-components: specifier: 0.27.5 - version: 0.27.5(@babel/parser@7.28.5)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)) + version: 0.27.5(@babel/parser@7.29.3)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)) unplugin-vue-router: specifier: 0.8.8 version: 0.8.8(rollup@4.52.5)(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) @@ -467,6 +479,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.11.3': + resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -554,6 +571,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-decorators@7.28.0': resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==} engines: {node: '>=6.9.0'} @@ -611,6 +633,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@boundaries/elements@2.0.1': resolution: {integrity: sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==} engines: {node: '>=18.18'} @@ -1157,6 +1183,20 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/experimental-ct-core@1.59.1': + resolution: {integrity: sha512-U7+jNROBJxfwjM/G7011+UNEyLiI5zIT1HWAn1k89WZIWl5RUWaCGWlYkYdAZwBSVfGstjF9AgkzmS0RsF8Ulw==} + engines: {node: '>=18'} + + '@playwright/experimental-ct-vue@1.59.1': + resolution: {integrity: sha512-RygXcwXQwRHzcdaQAXpKiHEl8XDPepZKmfHDNPCSnCN1g9ylaAvtNF6s7DgpsxlHqLlwgc2DmMr1n3D/OOVKyQ==} + engines: {node: '>=18'} + hasBin: true + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2013,6 +2053,13 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 vue: ^3.0.0 + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.1': resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2086,9 +2133,15 @@ packages: '@vue/compiler-core@3.5.22': resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + '@vue/compiler-dom@3.5.22': resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + '@vue/compiler-sfc@3.5.22': resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==} @@ -2143,6 +2196,9 @@ packages: '@vue/shared@3.5.22': resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + '@vue/test-utils@2.4.9': resolution: {integrity: sha512-YwgowiO1mPleZqpgAGfxvWu/A5A8nkLrbyH2SqiQRkyzCIaDzzo27/2uS/F1g7fRLvl8BUY0+Sr1eC+6+IHfrw==} peerDependencies: @@ -3219,6 +3275,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4207,6 +4268,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5212,6 +5283,46 @@ packages: peerDependencies: vue: '>=3.2.13' + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.1.12: resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5703,6 +5814,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.11.3(playwright-core@1.59.1)': + dependencies: + axe-core: 4.11.4 + playwright-core: 1.59.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5826,6 +5942,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -5896,6 +6016,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@boundaries/elements@2.0.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)': dependencies: eslint-import-resolver-node: 0.3.9 @@ -6254,12 +6379,12 @@ snapshots: '@intlify/shared@11.1.12': {} - '@intlify/unplugin-vue-i18n@11.0.1(@vue/compiler-dom@3.5.22)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.1(@vue/compiler-dom@3.5.34)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) '@intlify/bundle-utils': 11.0.1(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3))) '@intlify/shared': 11.1.12 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.22)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.34)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) @@ -6278,12 +6403,12 @@ snapshots: - supports-color - typescript - '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.22)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.34)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': dependencies: '@babel/parser': 7.28.5 optionalDependencies: '@intlify/shared': 11.1.12 - '@vue/compiler-dom': 3.5.22 + '@vue/compiler-dom': 3.5.34 vue: 3.5.22(typescript@5.9.3) vue-i18n: 11.1.12(vue@3.5.22(typescript@5.9.3)) @@ -6385,6 +6510,47 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/experimental-ct-core@1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)': + dependencies: + playwright: 1.59.1 + playwright-core: 1.59.1 + vite: 6.4.2(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@playwright/experimental-ct-vue@1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))(yaml@2.8.1)': + dependencies: + '@playwright/experimental-ct-core': 1.59.1(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitejs/plugin-vue': 5.2.4(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - vite + - vue + - yaml + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} '@popperjs/core@2.11.8': {} @@ -6614,11 +6780,11 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.22)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.34)(@vue/compiler-sfc@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 9.3.4 - '@vue/test-utils': 2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + '@vue/test-utils': 2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) vue: 3.5.22(typescript@5.9.3) optionalDependencies: '@vue/compiler-sfc': 3.5.22 @@ -7257,6 +7423,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@5.2.4(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))': + dependencies: + vite: 7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1) + vue: 3.5.22(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 @@ -7365,11 +7536,24 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + '@vue/compiler-dom@3.5.22': dependencies: '@vue/compiler-core': 3.5.22 '@vue/shared': 3.5.22 + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + '@vue/compiler-sfc@3.5.22': dependencies: '@babel/parser': 7.28.5 @@ -7436,7 +7620,7 @@ snapshots: '@vue/language-core@3.1.2(typescript@5.9.3)': dependencies: '@volar/language-core': 2.4.23 - '@vue/compiler-dom': 3.5.22 + '@vue/compiler-dom': 3.5.34 '@vue/shared': 3.5.22 alien-signals: 3.0.3 muggle-string: 0.4.1 @@ -7469,9 +7653,11 @@ snapshots: '@vue/shared@3.5.22': {} - '@vue/test-utils@2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@vue/shared@3.5.34': {} + + '@vue/test-utils@2.4.9(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': dependencies: - '@vue/compiler-dom': 3.5.22 + '@vue/compiler-dom': 3.5.34 js-beautify: 1.15.4 vue: 3.5.22(typescript@5.9.3) vue-component-type-helpers: 3.2.7 @@ -8853,6 +9039,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9901,6 +10090,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} pngjs@5.0.0: {} @@ -10933,7 +11130,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue-components@0.27.5(@babel/parser@7.28.5)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)): + unplugin-vue-components@0.27.5(@babel/parser@7.29.3)(rollup@4.52.5)(vue@3.5.22(typescript@5.9.3)): dependencies: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.3.0(rollup@4.52.5) @@ -10947,7 +11144,7 @@ snapshots: unplugin: 1.16.1 vue: 3.5.22(typescript@5.9.3) optionalDependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.3 transitivePeerDependencies: - rollup - supports-color @@ -11099,7 +11296,7 @@ snapshots: '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) - '@vue/compiler-dom': 3.5.22 + '@vue/compiler-dom': 3.5.34 kolorist: 1.8.0 magic-string: 0.30.21 vite: 7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1) @@ -11128,6 +11325,21 @@ snapshots: svgo: 3.3.2 vue: 3.5.22(typescript@5.9.3) + vite@6.4.2(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.9.2 + fsevents: 2.3.3 + sass: 1.76.0 + tsx: 4.20.6 + yaml: 2.8.1 + vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.11 diff --git a/apps/app/tests/playwright-ct/smoke.spec.ts b/apps/app/tests/playwright-ct/smoke.spec.ts new file mode 100644 index 00000000..10ecff83 --- /dev/null +++ b/apps/app/tests/playwright-ct/smoke.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/experimental-ct-vue' + +// B1 smoke: proves Playwright Component Testing is wired and Chromium +// boots. Replaces no functionality; deleted once a real component +// test (B2 sanity) supersedes it. +test('chromium boots in CT runner', async ({ page }) => { + expect(page).toBeTruthy() +}) diff --git a/lefthook.yml b/lefthook.yml index a8a0fc33..ff45bb65 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -29,3 +29,10 @@ pre-push: # behaviour. (Pushing with zero new commits would be skipped # under lefthook but is a no-op for the sync-staleness warning # anyway, so behaviour stays effectively 1:1.) + git-lfs: + # `git lfs install --skip-repo` only sets up global clean/smudge + # filters — it does NOT install the per-repo pre-push hook + # (which would conflict with lefthook). We delegate the LFS + # upload step here so screenshot baselines tracked via LFS get + # pushed alongside their commits. + run: git lfs pre-push {1} {2} -- 2.39.5 From 82af11754a93a979badbfa390034bb9c633c6513 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 14:56:48 +0200 Subject: [PATCH 2/9] test(infra): mountWithProviders helper + Vuetify CT sanity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add tests/playwright-ct/utils/mountWithProviders.ts: ergonomic wrapper around Playwright CT's mount() exposing buildMountArgs() and readNotificationState(). Documents the Vue Test Utils ↔ Playwright CT API divergence (provider plugins must be wired in beforeMount, not at call time) and the Vuetify-temp lifecycle (replaced by PrimeVue in F3). - Add tests/playwright-ct/components/SanityButtonHarness.vue: a v-btn harness with a click counter; lives in a .vue file so Vite bundles its CSS-side-effect imports for the browser context (Playwright CT runs the test orchestrator in Node and components in a Vite-bundled browser, unlike Vitest's single jsdom graph). - Add tests/playwright-ct/components/sanity-vuetify.spec.ts: two tests proving (a) v-btn renders and propagates clicks, (b) the --v-theme-primary CSS variable resolves to a parseable RGB triplet. - Update playwright/index.ts: import 'vuetify/styles' so the v-btn renders with its actual visual appearance (not unstyled). Required for B3's visual baselines. 3 component tests pass. 402 Vitest tests still pass unchanged. Lint + typecheck clean on new files. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/playwright/index.ts | 9 +- .../components/SanityButtonHarness.vue | 20 +++ .../components/sanity-vuetify.spec.ts | 50 +++++++ .../playwright-ct/utils/mountWithProviders.ts | 123 ++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 apps/app/tests/playwright-ct/components/SanityButtonHarness.vue create mode 100644 apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts create mode 100644 apps/app/tests/playwright-ct/utils/mountWithProviders.ts diff --git a/apps/app/playwright/index.ts b/apps/app/playwright/index.ts index 3a85b0f1..5ad543fa 100644 --- a/apps/app/playwright/index.ts +++ b/apps/app/playwright/index.ts @@ -10,8 +10,13 @@ import { type ThemeDefinition, createVuetify } from 'vuetify' import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports import * as directives from 'vuetify/directives' -// Plain-CSS token sheet — JSDOM evaluates :root custom properties from -// this import so getComputedStyle(el).getPropertyValue('--tt-status-…') +// Vuetify base styles — required for v-btn / v-chip / v-card etc. to +// render with their actual visual appearance (not a default browser +// look). Without this, Playwright visual baselines would capture +// unstyled components. Removed in F3 alongside the Vuetify plugin. +import 'vuetify/styles' + +// Plain-CSS token sheet — getComputedStyle(el).getPropertyValue('--tt-status-…') // resolves during component tests. Path resolved by the alias map in // playwright-ct.config.ts. import '@/styles/tokens/_timetable.css' diff --git a/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue new file mode 100644 index 00000000..17f792ab --- /dev/null +++ b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts new file mode 100644 index 00000000..35f7ccec --- /dev/null +++ b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import SanityButtonHarness from './SanityButtonHarness.vue' + +// B2 sanity — proves the full provider stack from playwright/index.ts +// is wired: +// - Vuetify renders v-btn with theme tokens applied +// - Click events propagate via Vue's reactivity (counter ref updates) +// - Vuetify CSS variables resolve in computed style +// +// TEMPORARY VUETIFY: this test is replaced by a PrimeVue equivalent +// in F3. Do not extend or generalise — F3 rewrites it. See +// dev-docs/ARCH-TESTING.md §6. +// +// Why a .vue harness file: Playwright CT runs the test orchestrator +// in Node and the component in a Vite-bundled browser context. Vue +// components that pull in CSS-side-effect imports (Vuetify) cannot be +// loaded directly into the test's Node module graph; they must live +// in a .vue / Vite-compilable file. This is a structural divergence +// from Vitest, which uses jsdom and one module graph for both. + +test.describe('B2 sanity: provider stack', () => { + test('mounts a Vuetify v-btn and propagates clicks', async ({ mount }) => { + const component = await mount(SanityButtonHarness) + const btn = component.locator('[data-test="btn"]') + + await expect(btn).toBeVisible() + await expect(btn).toContainText('Clicks: 0') + + await btn.click() + await expect(btn).toContainText('Clicks: 1') + + await btn.click() + await expect(btn).toContainText('Clicks: 2') + }) + + test('Vuetify primary theme color resolves on rendered button', async ({ mount, page }) => { + await mount(SanityButtonHarness) + + // Vuetify exposes theme primary as "R, G, B" decimals on + // --v-theme-primary (e.g. "31, 122, 209" for #1f7ad1). + const themePrimary = await page.evaluate(() => { + const root = document.documentElement + + return getComputedStyle(root).getPropertyValue('--v-theme-primary').trim() + }) + + expect(themePrimary).not.toBe('') + expect(themePrimary.split(',')).toHaveLength(3) + }) +}) diff --git a/apps/app/tests/playwright-ct/utils/mountWithProviders.ts b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts new file mode 100644 index 00000000..8aabd5ba --- /dev/null +++ b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts @@ -0,0 +1,123 @@ +import type { Locator } from '@playwright/test' +import type { Component } from 'vue' + +import type { HooksConfig } from '../../../playwright' + +/** + * mountWithProviders — Playwright Component Testing analogue of + * tests/utils/mountWithVuexy.ts. + * + * Why two helpers? + * ---------------- + * Playwright CT's `mount()` API is structurally different from + * @vue/test-utils' `mount()`: provider plugins (Vuetify, Pinia, + * Router, VueQuery) must be registered in `playwright/index.ts`'s + * `beforeMount` hook rather than passed at call time. This helper + * is a thin ergonomic wrapper that: + * + * - Forwards `hooksConfig` to the beforeMount hook (typed via + * HooksConfig from playwright/index.ts) + * - Returns the standard Playwright Locator from CT's mount, so + * downstream test code uses normal Playwright assertions + * (component.click(), expect(component).toBeVisible(), etc.) + * - Serves as a single, discoverable surface for "how do I mount + * a component in this codebase" questions + * + * Lifecycle note (TEMPORARY VUETIFY): + * ----------------------------------- + * The Vuetify plugin line in `playwright/index.ts`'s `beforeMount` + * hook is INTENTIONALLY temporary state. F3 (PrimeVue foundation, + * RFC-WS-FRONTEND-PRIMEVUE §6) replaces it with PrimeVue. We do NOT + * abstract behind a "pluggable UI framework" indirection because: + * + * 1. We are NOT retaining Vuetify; the abstraction would itself + * need to be removed in F3. + * 2. The swap is mechanical (~2-hour) and atomic; abstraction adds + * cognitive cost without paying back. + * 3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration + * sprint" should read this JSDoc and dev-docs/ARCH-TESTING.md §6 + * for context. + * + * Equivalence to mountWithVuexy.ts: + * --------------------------------- + * | Capability | Vitest (mountWithVuexy) | Playwright CT (this) | + * |----------------------------------|-------------------------------|----------------------| + * | Vuetify w/ tokens | createVuetify({components,…}) | beforeMount hook | + * | Pinia (actions execute) | createTestingPinia | createPinia | + * | TanStack Query (fresh client) | per-call new QueryClient | per-test in hook | + * | Memory-history router | per-call createRouter | per-test in hook | + * | initialPath / initialQuery | options.initialPath | hooksConfig.… | + * | Initial Pinia state | options.initialState | hooksConfig.piniaInitialState | + * | Notification mock | createNotificationMock + plug | (see assertNotification below) | + * + * Notification assertions: + * ------------------------ + * Playwright runs in a separate Node process from the browser and + * cannot use `vi.fn()` spies on store actions. Instead, tests assert + * on the rendered UI (e.g. `await expect(page.getByRole('alert')) + * .toContainText('Saved')`) or read pinia state via page.evaluate. + * This is a deliberate divergence from the Vitest pattern — UI + * assertions are stronger than spy assertions for a real-browser + * runner. + * + * Type signature constraint: + * -------------------------- + * Playwright CT's MountResult uses internal types. We accept the + * native MountOptions shape and wrap; tests should import the + * Playwright-CT `expect`/`test` and call `mountWithProviders` to + * get the locator back. + */ +export interface MountOptions { + props?: Record + slots?: Record + hooksConfig?: HooksConfig +} + +// Re-export so callers have one import surface. +export type { HooksConfig } from '../../../playwright' + +// The actual mount call must be done by the test using Playwright CT's +// `mount` fixture (`test('…', async ({ mount }) => …)`). We expose a +// helper that builds the options object correctly. This avoids the +// "two ways to mount" footgun: there's the Playwright fixture, and +// there's our wrapper that produces its arguments. +export function buildMountArgs( + component: C, + opts: MountOptions = {}, +): { + component: C + options: { props?: Record; slots?: Record; hooksConfig?: HooksConfig } + } { + return { + component, + options: { + props: opts.props, + slots: opts.slots, + hooksConfig: opts.hooksConfig, + }, + } +} + +/** + * Convenience: assert on the notification store via the browser's + * window.__pinia exposed in the beforeMount hook. Returns the current + * notification state as serialised JSON. + */ +export async function readNotificationState( + componentLocator: Locator, +): Promise<{ visible: boolean; message: string; type: string }> { + const page = componentLocator.page() + + return page.evaluate(() => { + interface Win { __pinia?: { state: { value: Record } } } + const w = window as unknown as Win + + const state = w.__pinia?.state?.value?.notification as + | { visible: boolean; message: string; type: string } + | undefined + + return state + ? { visible: state.visible, message: state.message, type: state.type } + : { visible: false, message: '', type: 'info' } + }) +} -- 2.39.5 From f6509d938b63864971df4b363de1db674d00b759 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 15:04:51 +0200 Subject: [PATCH 3/9] 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}`) +}) -- 2.39.5 From 2dfb1e8bae684df7c091072bf341ea5780818a09 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 15:24:33 +0200 Subject: [PATCH 4/9] test(e2e): real-backend 409 conflict contract test (TEST-CONTRACT-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B4 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add api/database/seeders/E2EBaselineSeeder.php — deterministic seed for Playwright e2e: e2e@test.local user (org_admin) on a fresh org + event + stage + StageDay + artist + engagement + performance (version=0). Writes seeded IDs to api/storage/app/e2e-fixtures.json so the Playwright fixture can construct API URLs without API discovery calls. - Add apps/app/tests/playwright-e2e/global-setup.ts — runs `php artisan migrate:fresh --force --seed` against crewli_test (the existing PHPUnit MySQL test DB) before the test suite starts. Uses --env=testing to satisfy the dangerous-bash hook's migrate:fresh guard. - Add apps/app/tests/playwright-e2e/utils/fixtures.ts — typed reader for e2e-fixtures.json. Cached after first read. - Add apps/app/tests/playwright-e2e/utils/auth.ts — login helper that POSTs /api/v1/auth/login and returns user/org IDs. Uses Bearer-via- cookie flow (per api/.../SetAuthCookie.php), not stateful Sanctum. - Add apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts — the contract test: first move with version=0 returns 200, second move with same stale version returns 409 with shape `errors.conflict: 'version_mismatch'`. Catches the schema-drift bug class that timetable-stabilization B5 surfaced. - Update apps/app/playwright.config.ts — wire globalSetup, webServer for `php artisan serve --port=8001`, baseURL `http://localhost:8001` (NOT 127.0.0.1 — auth cookie's domain=localhost requires hostname match). - Update .gitignore — runtime e2e-fixtures.json never committed. DoD-19 met locally: `pnpm test:e2e` passes against a real Laravel test server. CI integration deferred to TEST-INFRA-002 (per A-1 amendment). Constraint: e2e tests share the crewli_test DB with PHPUnit. Running both concurrently would collide. Documented in ARCH-TESTING.md (B5). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + api/database/seeders/E2EBaselineSeeder.php | 112 ++++++++++++++++++ apps/app/playwright.config.ts | 48 ++++++-- apps/app/tests/playwright-e2e/global-setup.ts | 72 +++++++++++ .../timetable/409-conflict.spec.ts | 112 ++++++++++++++++++ apps/app/tests/playwright-e2e/utils/auth.ts | 60 ++++++++++ .../tests/playwright-e2e/utils/fixtures.ts | 42 +++++++ 7 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 api/database/seeders/E2EBaselineSeeder.php create mode 100644 apps/app/tests/playwright-e2e/global-setup.ts create mode 100644 apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts create mode 100644 apps/app/tests/playwright-e2e/utils/auth.ts create mode 100644 apps/app/tests/playwright-e2e/utils/fixtures.ts diff --git a/.gitignore b/.gitignore index 6a973024..2d89c75c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ apps/app/playwright-report/ apps/app/blob-report/ apps/app/playwright/.cache/ +# Playwright e2e seed-data fixture file — written by E2EBaselineSeeder +# during globalSetup, never source-of-truth. +api/storage/app/e2e-fixtures.json + # Misc *.pem .cache/ diff --git a/api/database/seeders/E2EBaselineSeeder.php b/api/database/seeders/E2EBaselineSeeder.php new file mode 100644 index 00000000..c6e9321e --- /dev/null +++ b/api/database/seeders/E2EBaselineSeeder.php @@ -0,0 +1,112 @@ +call(RoleSeeder::class); + + $org = Organisation::factory()->create([ + 'name' => 'E2E Test Organisation', + ]); + + $user = User::factory()->create([ + 'email' => 'e2e@test.local', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ]); + $org->users()->attach($user, ['role' => 'org_admin']); + + $event = Event::factory()->create([ + 'organisation_id' => $org->id, + 'name' => 'E2E Test Festival', + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + + $stage = Stage::factory()->create([ + 'event_id' => $event->id, + 'name' => 'E2E Stage', + ]); + + StageDay::query()->create([ + 'stage_id' => $stage->id, + 'event_id' => $event->id, + ]); + + $artist = Artist::factory()->create([ + 'organisation_id' => $org->id, + 'name' => 'E2E Artist', + ]); + + $engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + Performance::factory()->create([ + 'engagement_id' => $engagement->id, + 'event_id' => $event->id, + 'stage_id' => $stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $performance = Performance::query() + ->where('event_id', $event->id) + ->where('stage_id', $stage->id) + ->first(); + + // Write seeded IDs to a known location the Playwright e2e + // fixture reads. Avoids artisan-stdout-parsing fragility. + $fixturePath = storage_path('app/e2e-fixtures.json'); + @mkdir(dirname($fixturePath), 0755, true); + file_put_contents($fixturePath, json_encode([ + 'user_email' => 'e2e@test.local', + 'user_password' => 'password', + 'organisation_id' => $org->id, + 'event_id' => $event->id, + 'stage_id' => $stage->id, + 'performance_id' => $performance?->id, + ], JSON_PRETTY_PRINT)); + + $this->command?->info("E2E fixtures written to {$fixturePath}"); + } +} diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index 8b6dfb26..588023ca 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -1,8 +1,32 @@ import { defineConfig, devices } from '@playwright/test' -// E2E config — drives a real Vite dev server + a real Laravel test -// server. Used by `pnpm test:e2e`. Component tests live in -// playwright-ct.config.ts (different runner). +// E2E config — drives a real Laravel test server. Used by `pnpm test:e2e`. +// Component tests live in playwright-ct.config.ts (different runner). +// +// Architecture (B4 / TEST-CONTRACT-001): +// - globalSetup runs `php artisan migrate:fresh --force --seed` against +// crewli_test (the existing PHPUnit MySQL test DB) using the +// E2EBaselineSeeder. Writes seeded IDs to api/storage/app/e2e-fixtures.json. +// - webServer auto-starts `php artisan serve --port=8001` against the +// same DB so the test can hit a live HTTP endpoint. +// - Tests use page.context().request to call /api/v1/* with cookie auth +// established via /api/v1/auth/login (Bearer-via-cookie, not stateful +// Sanctum SPA flow — see api/.../SetAuthCookie.php). +// +// SCOPE: contract tests only (request shape verification). UI-driven e2e +// tests would additionally need the Vite dev server (port 5174). Add that +// to webServer when first UI-driven e2e test lands. +// +// CI integration: deferred to TEST-INFRA-002. This config currently +// targets local-machine execution. + +const E2E_API_PORT = Number(process.env.E2E_API_PORT ?? 8001) + +// Use `localhost` (not 127.0.0.1) so the auth cookie set with +// `domain=localhost` (per api/.env's SESSION_DOMAIN) is matched on +// subsequent requests. Playwright's APIRequestContext respects +// cookie scope strictly. +const E2E_API_URL = `http://localhost:${E2E_API_PORT}` export default defineConfig({ testDir: './tests/playwright-e2e', @@ -11,13 +35,17 @@ export default defineConfig({ retries: 0, workers: 1, reporter: process.env.CI ? 'github' : 'list', + globalSetup: './tests/playwright-e2e/global-setup.ts', use: { - baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + baseURL: E2E_API_URL, trace: 'off', video: 'off', screenshot: 'off', viewport: { width: 1440, height: 900 }, + extraHTTPHeaders: { + Accept: 'application/json', + }, }, projects: [ @@ -27,14 +55,14 @@ export default defineConfig({ }, ], - // Auto-start the SPA dev server. Laravel's test server is started - // by the per-test fixture in tests/playwright-e2e/fixtures/laravel.ts - // because its lifecycle requires per-run seed control. + // Auto-start the Laravel test server. globalSetup runs first + // (migrate:fresh + seed), then this command spawns artisan serve + // against the now-seeded crewli_test DB. webServer: { - command: 'pnpm dev', - url: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + command: 'cd ../../api && DB_DATABASE=crewli_test php artisan --env=testing serve --port=8001', + url: `${E2E_API_URL}/up`, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 60_000, stdout: 'ignore', stderr: 'pipe', }, diff --git a/apps/app/tests/playwright-e2e/global-setup.ts b/apps/app/tests/playwright-e2e/global-setup.ts new file mode 100644 index 00000000..2a3e888a --- /dev/null +++ b/apps/app/tests/playwright-e2e/global-setup.ts @@ -0,0 +1,72 @@ +import { execSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Playwright globalSetup — runs once before all e2e tests. +// +// Responsibilities: +// 1. Verify api/.env exists (Laravel needs APP_KEY etc.) +// 2. Run `php artisan migrate:fresh --force --seed --seeder=E2EBaselineSeeder` +// against crewli_test (override DB_DATABASE on the command line). +// The seeder writes seeded IDs to api/storage/app/e2e-fixtures.json. +// 3. Verify the fixtures file was produced. +// +// Failures here abort the test run before any spec runs — prevents +// confusing per-test failures when the test DB is in an unexpected state. +// +// Why crewli_test (and not a dedicated crewli_e2e DB): +// The PHPUnit suite already uses crewli_test with `RefreshDatabase` +// (transaction-rollback per test). Running e2e and PHPUnit +// concurrently would collide; the lifecycle here assumes serial +// execution, which is the realistic local-dev flow. Documented in +// dev-docs/ARCH-TESTING.md §5 (mock-vs-real-backend). + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..') +const API_DIR = path.join(REPO_ROOT, 'api') +const FIXTURES_PATH = path.join(API_DIR, 'storage', 'app', 'e2e-fixtures.json') + +export default async function globalSetup(): Promise { + console.log('[e2e setup] Repo root:', REPO_ROOT) + console.log('[e2e setup] API dir:', API_DIR) + + if (!existsSync(path.join(API_DIR, '.env'))) { + throw new Error( + '[e2e setup] api/.env not found. Run `cp api/.env.example api/.env` and configure DB_* + APP_KEY.', + ) + } + + console.log('[e2e setup] Running migrate:fresh + E2EBaselineSeeder against crewli_test...') + + // --env=testing avoids the dangerous-bash hook's migrate:fresh guard + // (which requires --env=testing to permit the destructive op). + // DB_DATABASE override forces crewli_test even though the .env may + // point at the dev DB. + try { + execSync( + 'DB_DATABASE=crewli_test php artisan --env=testing migrate:fresh --force --seed --seeder="Database\\\\Seeders\\\\E2EBaselineSeeder"', + { + cwd: API_DIR, + stdio: 'inherit', + env: { ...process.env, DB_DATABASE: 'crewli_test' }, + }, + ) + } + catch (err) { + throw new Error(`[e2e setup] migrate:fresh + seed failed: ${(err as Error).message}`) + } + + if (!existsSync(FIXTURES_PATH)) { + throw new Error( + `[e2e setup] Expected fixtures file at ${FIXTURES_PATH} but it does not exist. ` + + 'Did E2EBaselineSeeder run? Check the artisan output above.', + ) + } + + const fixtures = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as Record + + console.log('[e2e setup] Fixtures ready:', Object.keys(fixtures).join(', ')) +} diff --git a/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts new file mode 100644 index 00000000..571f0205 --- /dev/null +++ b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test' + +import { loginAsBaselineUser } from '../utils/auth' +import { readFixtures } from '../utils/fixtures' + +// ============================================================================= +// TEST-CONTRACT-001 — 409 conflict shape on optimistic-locking failure +// ============================================================================= +// +// Why this test exists: +// --------------------- +// The timetable-stabilization sprint's B5 incident (commit ff056e3) +// caught a schema-drift bug where the frontend's Zod schema for +// MoveTimetableConflictResponse diverged from the backend's response +// shape. Vitest+jsdom tests passed because they mocked both sides. +// This test hits the REAL backend and validates the response shape +// against what the SPA actually parses. +// +// Scenario: +// --------- +// 1. Login as e2e@test.local (seeded via E2EBaselineSeeder). +// 2. POST /timetable/move with version=0 → expect 200 (succeeds; perf +// version becomes 1). +// 3. POST same move again with version=0 → expect 409 with shape: +// { success: false, errors: { conflict: 'version_mismatch' }, ... } +// 4. Assert structural properties of the 409 response so future +// backend changes that drift this shape FAIL the test instead of +// silently passing through mock-equipped Vitest assertions. +// +// What this test does NOT cover: +// ------------------------------ +// - Multi-context race (two real browsers): single-context replay is +// functionally equivalent for contract validation; the 409 is +// returned by the server based on stored version, not on session +// identity. +// - UI rollback behaviour: that's a future UI-driven e2e that needs +// the Vite webServer running. Out of B4 scope. +// - Exhaustive 4xx/5xx variants: only the 409 shape is the documented +// regression risk. +// ============================================================================= + +test.describe('TEST-CONTRACT-001 — timetable move 409 conflict', () => { + test('second move with stale version returns 409 with version_mismatch', async ({ browser }) => { + const fixtures = readFixtures() + const context = await browser.newContext({ baseURL: process.env.E2E_API_URL ?? 'http://localhost:8001' }) + const { request, organisationId } = await loginAsBaselineUser(context) + + expect(organisationId).toBe(fixtures.organisation_id) + + const moveUrl = `/api/v1/organisations/${fixtures.organisation_id}/events/${fixtures.event_id}/timetable/move` + + // Use a stable target time independent of the original seed, so + // we control where the perf lands. Take the seeded perf's + // engagement window and shift by an hour. + const targetStart = new Date() + + targetStart.setUTCDate(targetStart.getUTCDate() + 2) + targetStart.setUTCHours(21, 0, 0, 0) // 23:00 Europe/Amsterdam summer + + const targetEnd = new Date(targetStart.getTime() + 60 * 60 * 1000) + + const fmt = (d: Date) => d.toISOString().slice(0, 19).replace('T', ' ') + + const movePayload = { + performance_id: fixtures.performance_id, + target_stage_id: fixtures.stage_id, + target_start_at: fmt(targetStart), + target_end_at: fmt(targetEnd), + target_lane: 0, + version: 0, + } + + // ----------------------------------------------------------------- + // First move: expect 200, perf.version moves 0 → 1 + // ----------------------------------------------------------------- + const first = await request.post(moveUrl, { + data: movePayload, + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'e2e-409-first', + }, + }) + + expect(first.status(), `First move expected 200, got ${first.status()}: ${await first.text()}`).toBe(200) + + // ----------------------------------------------------------------- + // Second move with the SAME (now-stale) version=0: expect 409 + // ----------------------------------------------------------------- + const second = await request.post(moveUrl, { + data: movePayload, + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'e2e-409-second', + }, + }) + + expect(second.status()).toBe(409) + + const conflictBody = await second.json() as { + success?: boolean + message?: string + errors?: { conflict?: string } + } + + // Contract assertions — drift here means the SPA's Zod schema for + // MoveTimetableConflictResponse needs an update. + expect(conflictBody.errors?.conflict).toBe('version_mismatch') + expect(typeof conflictBody.message).toBe('string') + + await context.close() + }) +}) diff --git a/apps/app/tests/playwright-e2e/utils/auth.ts b/apps/app/tests/playwright-e2e/utils/auth.ts new file mode 100644 index 00000000..1a24e5f6 --- /dev/null +++ b/apps/app/tests/playwright-e2e/utils/auth.ts @@ -0,0 +1,60 @@ +import type { APIRequestContext, BrowserContext } from '@playwright/test' + +import { readFixtures } from './fixtures' + +// Login helper for Playwright e2e tests. +// +// Uses the SPA-style Bearer-via-cookie flow (see +// api/.../Traits/SetAuthCookie.php): POST /api/v1/auth/login returns a +// `crewli_app_token` httpOnly cookie. Subsequent /api/v1/* requests in +// the same browser context carry it automatically because Playwright's +// request fixture inherits cookies from the BrowserContext. +// +// NOT sanctum-stateful (CSRF-cookie + X-XSRF-TOKEN). The custom +// CookieBearerToken middleware (api/bootstrap/app.php) reads the +// auth cookie directly. + +export interface LoginResult { + request: APIRequestContext + userId: string + organisationId: string +} + +/** + * Authenticates the e2e baseline user against a freshly-seeded + * Laravel test server. Returns the request context (auth cookie set) + * and the user/org IDs from the response. + */ +export async function loginAsBaselineUser(context: BrowserContext): Promise { + const fixtures = readFixtures() + + const response = await context.request.post('/api/v1/auth/login', { + data: { + email: fixtures.user_email, + password: fixtures.user_password, + }, + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok()) { + throw new Error( + `Login failed: ${response.status()} — ${await response.text()}`, + ) + } + + const body = await response.json() as { + success: boolean + data: { + user: { + id: string + organisations: Array<{ id: string; role: string }> + } + } + } + + return { + request: context.request, + userId: body.data.user.id, + organisationId: body.data.user.organisations[0].id, + } +} diff --git a/apps/app/tests/playwright-e2e/utils/fixtures.ts b/apps/app/tests/playwright-e2e/utils/fixtures.ts new file mode 100644 index 00000000..1c7012af --- /dev/null +++ b/apps/app/tests/playwright-e2e/utils/fixtures.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Reads the seeded IDs that E2EBaselineSeeder wrote during globalSetup. +// Tests use these IDs to construct API URLs and verify response shapes. + +export interface E2EFixtures { + user_email: string + user_password: string + organisation_id: string + event_id: string + stage_id: string + performance_id: string +} + +const FIXTURES_PATH = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + '..', + 'api', + 'storage', + 'app', + 'e2e-fixtures.json', +) + +let cached: E2EFixtures | null = null + +export function readFixtures(): E2EFixtures { + if (cached) + return cached + + cached = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as E2EFixtures + + return cached +} -- 2.39.5 From 7e21c6a6332b8afaae669e6d982d358fa6f30b3e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 15:29:18 +0200 Subject: [PATCH 5/9] =?UTF-8?q?docs(testing):=20add=20ARCH-TESTING.md=20?= =?UTF-8?q?=E2=80=94=20test=20pyramid,=20scope=20per=20tier,=20anti-patter?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B5 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add dev-docs/ARCH-TESTING.md (~13 KB): §1 Five-tier pyramid (Unit / Component / Integration / Visual / E2E) with environment, cost, and purpose per tier §2 Decision tree — pick by what is being verified, not by speed §3 Mock-vs-real-backend rules + the self-confirming-bias anti- pattern that motivated TEST-CONTRACT-001 §4 Visual baseline workflow including the composite-over-isolated strategy used in B3 §5 CI strategy stub — deferred to TEST-INFRA-002 §6 Conventions + 5 anti-patterns §7 Vuetify-during-PrimeVue-migration: explicit doc that the Vuetify plugin in playwright/index.ts is INTENTIONAL TEMPORARY STATE replaced in F3 by PrimeVue. Forbids the "abstract the UI framework provider" deferred-cost trap. §8 Host setup — Node, pnpm, Chromium, Git LFS, MySQL 8, PHP, .env; known risks (unpkg.com flakiness, shared crewli_test DB) §9 Deferred work cross-references to BACKLOG entries - Update CLAUDE.md ### Testing section to reference ARCH-TESTING.md - Add ARCH-TESTING.md to .claude-sync.conf so the dev-docs sync pipeline picks it up; sync script run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-sync.conf | 1 + CLAUDE.md | 4 + dev-docs/ARCH-TESTING.md | 341 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 dev-docs/ARCH-TESTING.md diff --git a/.claude-sync.conf b/.claude-sync.conf index c5e007ee..bc39a1f0 100644 --- a/.claude-sync.conf +++ b/.claude-sync.conf @@ -16,6 +16,7 @@ dev-docs/ARCH-API-VALIDATION.md dev-docs/RFC-WS-7-OBSERVABILITY.md dev-docs/GLITCHTIP.md dev-docs/ARCH-OBSERVABILITY.md +dev-docs/ARCH-TESTING.md dev-docs/runbooks/observability-triage.md dev-docs/runbooks/observability-erasure.md dev-docs/RFC-WS-6.md diff --git a/CLAUDE.md b/CLAUDE.md index 01103de4..4c68aec1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,10 @@ PR as the migration. - Use factories for all test data - After each module: `php artisan test --filter=ModuleName` +Frontend test architecture (5 tiers: Unit / Component / Integration / +Visual / E2E) is documented in `dev-docs/ARCH-TESTING.md`. Choose the +right tier per the decision tree there before adding new tests. + ## Frontend rules (strict) ### Vuexy reference source (mandatory) diff --git a/dev-docs/ARCH-TESTING.md b/dev-docs/ARCH-TESTING.md new file mode 100644 index 00000000..97feaf2b --- /dev/null +++ b/dev-docs/ARCH-TESTING.md @@ -0,0 +1,341 @@ +# Crewli — Test Architecture + +> Authoritative reference for test-tier choices in the SPA. Read this +> before adding a new test. Linked from `CLAUDE.md`. + +This document describes: + +1. The test pyramid Crewli uses, and what each tier is for +2. When to use which tier (decision tree) +3. Mock-vs-real-backend rules +4. Visual baseline workflow +5. CI integration status +6. Conventions and anti-patterns +7. Vuetify-during-PrimeVue-migration: the temporary state in test infra +8. Host setup requirements +9. Deferred work (BACKLOG references) + +--- + +## 1. Test pyramid and scope per layer + +Crewli runs five test tiers in the SPA. Each has a narrow purpose; +overlap is wasted work, gaps are silent risk. Pick the tier whose +purpose matches what you're actually verifying. + +### Tier 1 — Unit (Vitest + happy-dom) + +**Run via:** `pnpm test` (filtered by `tests/unit/**`) +**Environment:** Node + happy-dom, single module graph +**Cost:** ~20 ms per test +**For:** Pure logic, schema parsing, store reducers, isolated composable +behaviour. No DOM. Fastest tier; safe for pre-commit if we ever add it. + +### Tier 2 — Component (Playwright Component Testing) + +**Run via:** `pnpm test:component` +**Environment:** Real Chromium via `@playwright/experimental-ct-vue` +**Cost:** ~300 ms per test (incl. Chromium reuse) +**For:** Single-component verification. DOM rendering, click/keyboard, +prop propagation, slot rendering, CSS resolution. Mocks API at axios +layer. Provider stack (Vuetify [TEMP], Pinia, TanStack Query, Router) is +wired in `apps/app/playwright/index.ts`'s `beforeMount` hook. + +### Tier 3 — Integration (Playwright CT, multi-component) + +**Run via:** Same `pnpm test:component` runner; placement convention +distinguishes integration from single-component. +**Cost:** ~500 ms per test +**For:** Page-level mounting with mocked API responses. Tests +cross-component coordination (drag from Wachtrij → canvas, popover +→ mutation flow). Same provider stack as Tier 2. + +### Tier 4 — Visual regression (Playwright CT, `@visual` tag) + +**Run via:** `pnpm test:visual` (verify), `pnpm test:visual:update` +(regenerate baselines) +**Environment:** Real Chromium driving the canonical prototype HTML +served by a tiny static-server fixture (`tests/playwright-ct/visual/ +static-server.mjs`). +**Cost:** ~1.2 s per test +**For:** Pixel baselines against canonical visual sources. The +prototype HTML at `resources/Crewli - Artist Timetable Management/ +crewli-timetable.html` is the source of truth for Artist Management +surfaces. F4 (component migration) extends visual coverage to live +SPA components against the prototype. + +### Tier 5 — E2E (Playwright) + +**Run via:** `pnpm test:e2e` +**Environment:** Real Laravel test server (`php artisan serve --port= +8001`, DB `crewli_test`) + real Chromium browser context. +**Cost:** ~5 s for the suite (includes migrate:fresh + seed) +**For:** Contract verification end-to-end. Real network, real auth, +real DB transactions. Currently only the 409-conflict optimistic- +locking contract test (TEST-CONTRACT-001). Add tests sparingly — this +is the most expensive tier. + +--- + +## 2. When to use what — decision tree + +``` +Is the thing under test pure logic with no DOM? + └─ YES → Unit (Vitest + happy-dom) + +Is it a single component? (props, events, slots, CSS, keyboard) + └─ YES → Component (Playwright CT) + +Is it cross-component coordination, but no real backend? + └─ YES → Integration (Playwright CT) + +Is it a contract between SPA and backend (request/response shape)? + └─ YES → E2E (Playwright + Laravel) + +Is it visual fidelity to a canonical baseline? + └─ YES → Visual (Playwright CT, @visual tag) +``` + +**Don't pick by speed.** Pick by what you're verifying. A unit test +that mocks the backend cannot catch a contract-drift bug; an e2e test +for pure logic is wasted CI time. + +--- + +## 3. Mock-vs-real-backend choice rules + +### Mock when + +- The test verifies SPA behaviour given a known response shape +- Backend availability would slow the test below the relevant tier's + cost budget +- The path under test is independent of transactional / auth + semantics + +### Real backend when + +- The test verifies the contract between frontend and backend (Zod + schema vs. PHP Resource shape) +- Authentication or authorisation flows are involved +- Optimistic-locking, idempotency, or other multi-request semantics + matter + +**Anti-pattern: matching mocks to schemas.** Don't mock with the same +shape your Zod schema validates — that creates self-confirming bias +where both sides agree but neither matches reality. This is the +exact failure mode TEST-CONTRACT-001 was created to catch (timetable- +stabilization B5). + +--- + +## 4. Visual baseline workflow + +### Capturing baselines + +```bash +pnpm test:visual:update +``` + +Reviews PNG diffs in PRs. Baselines live at: +``` +apps/app/tests/playwright-ct/__screenshots__/visual//.png +``` +Tracked via Git LFS (see `.gitattributes`). Pixel tolerance: +`maxDiffPixelRatio: 0.001` (0.1%) per `playwright-ct.config.ts`. + +### Updating baselines (intentional UX change) + +1. Make the UX change (component edit, token edit, …) +2. Run `pnpm test:visual:update` locally +3. Review the diff PNG manually — does the new baseline match the + intended UX? +4. Commit baseline + UX change in the **same PR**. Reviewer can + compare baseline change against the UX intent. +5. Never update baselines to "make tests pass" without a UX-justified + reason in the PR description. + +### Updating baselines (unintentional diff in CI) + +1. Determine if the diff is environmental (font hinting, OS rendering, + timezone-based date formatting) or a real regression. +2. Environmental → consider tightening determinism (lock fonts, fake + timers, fixed locale) before tweaking tolerance. +3. Real regression → fix the regression, not the baseline. + +### Composite-over-isolated strategy (B3 baselines) + +Some surfaces enumerated in RFC §A.3's baseline list are captured as +composite views rather than individual block-state baselines. Reason: +the prototype's DOM exposes status only via inline `style.background`, +no `data-*` attributes. Isolated locators (e.g. by artist name) lock +the test to specific seed data and silently rot if data changes. + +The current 5 baselines cover the visual vocabulary: + +| File | Captures | +| ----------------------------- | ------------------------------------------------------- | +| `canvas-friday.png` | Status colors, b2b indicators, multi-lane stacking | +| `canvas-saturday.png` | Conflict ring, capacity warning | +| `stage-row-multilane.png` | First row in isolation | +| `wachtrij-populated.png` | Sidebar list rendering, status badges, counts | +| `popover.png` | Block-click popover layout | + +9 additional surfaces are documented as `test.skip()` in +`tests/playwright-ct/visual/prototype.spec.ts` with the gap reason. +F4 component migration adds isolated baselines using stable +`data-test-id` attributes on Vue components. + +--- + +## 5. CI integration + +**Status: deferred.** The repo currently has no CI runner configured. +Local development workflow: + +- Vitest (`pnpm test`) — tier 1, runs on demand +- Playwright Component (`pnpm test:component`) — tiers 2–4, runs on + demand +- Playwright E2E (`pnpm test:e2e`) — tier 5, runs on demand against a + developer-managed Laravel test server + +CI design (Gitea Actions vs. GitHub Actions decision, Linux runner +image with PHP+MySQL+Node+pnpm, screenshot-diff artifact upload, +label-gated nightly e2e) is captured as `TEST-INFRA-002` in +`dev-docs/BACKLOG.md`. + +When CI lands: + +- Pre-commit (lefthook): Vitest unit only. Fast, no Playwright launch. +- PR-CI: Vitest unit + Playwright component + visual. Slower but full + coverage. +- Nightly / label-gated: Playwright e2e against real Laravel + MySQL. + Most expensive tier. + +--- + +## 6. Conventions + +- **Test file naming:** `*.spec.ts` for Playwright (CT + e2e), + `*.test.ts` for Vitest. The runner config glob keeps them apart. +- **`@visual` tag:** required on all visual-regression tests so + `--grep @visual` filters them. +- **Provider stack for CT:** wired in `apps/app/playwright/index.ts`'s + `beforeMount` hook, not at mount call time. Tests forward + per-test overrides via `hooksConfig` (see + `tests/playwright-ct/utils/mountWithProviders.ts`). +- **E2E test isolation:** `globalSetup` runs `migrate:fresh + seed` + once per `pnpm test:e2e` invocation. Tests within one run share DB + state. Re-run = fresh DB. +- **Pixel tolerance:** `maxDiffPixelRatio: 0.001` default + (`playwright-ct.config.ts`). Per-test exceptions allowed if + documented inline. +- **Auth in e2e tests:** Bearer-via-cookie (`api/.../SetAuthCookie.php`). + POST `/api/v1/auth/login` returns `crewli_app_token` httpOnly cookie. + No CSRF dance, no Sanctum stateful flow. baseURL must be + `localhost:8001` (matching the cookie's `domain=localhost`), + **not** `127.0.0.1:8001`. + +### Anti-patterns to avoid + +1. **Mocking the same data shape that the schema validates** — + creates self-confirming bias. Use real backend for contract tests + (TEST-CONTRACT-001 catches this class of bug). +2. **Updating baselines silently** without diff review or a UX- + justified PR description. +3. **Adding Playwright tests for pure logic** that Vitest can cover + in 20 ms. Reserve Playwright for tests that need the browser. +4. **Treating "small" UX changes as not needing visual updates** — + there is no small visual change in an enterprise product; the + user notices. +5. **Brittle locators** by data values (artist names, stage names) + instead of stable test IDs. F4 will add `data-test-id` to Vue + components for this reason. + +--- + +## 7. Vuetify in test infrastructure during the PrimeVue migration + +`apps/app/playwright/index.ts`'s `beforeMount` hook registers Vuetify +as a Vue plugin. This is **intentional temporary state**. + +### Why + +The current SPA still ships Vuetify. Component-level Playwright CT +tests must mount components against the same UI framework the live +app uses, otherwise they would test a non-existent surface. Stripping +Vuetify from test infra now would make CT tests un-runnable until +F3 lands PrimeVue. + +### When it ends + +F3 (PrimeVue foundation, RFC-WS-FRONTEND-PRIMEVUE §6) replaces the +Vuetify plugin line in `playwright/index.ts` with PrimeVue and +updates `tests/playwright-ct/components/sanity-vuetify.spec.ts` to +its PrimeVue equivalent. Estimated effort: ~2 hours (mechanical +swap, no architecture change). + +### Why not abstract + +The instinct of "abstract the UI framework provider so we can swap +without touching test code" is a **deferred-cost trap** here: + +1. We are NOT retaining Vuetify post-F3. The abstraction would itself + need to be removed in F4 alongside the framework swap. +2. The swap is mechanical (~2 hours). An abstraction layer would take + longer to design well than the swap itself takes. +3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration + sprint" should read this section + the JSDoc on + `mountWithProviders.ts` for context. + +The forbidden pattern: do not propose "let's make a `UIFrameworkPlugin` +interface and dependency-inject the provider per test" during F2/F3. +That's exactly the abstraction this section forbids. + +--- + +## 8. Host setup requirements + +For Playwright tests to run, the host must have: + +- **Node v22+** with **pnpm 10+** (matching `apps/app/`'s expectations) +- **Chromium** installed via `pnpm exec playwright install chromium` + (downloads to `~/Library/Caches/ms-playwright` on macOS) +- **Git LFS** installed (`brew install git-lfs` on macOS) and active + (`git lfs install --skip-repo` to avoid hook conflict with lefthook; + the LFS pre-push step is delegated through `lefthook.yml`) +- **MySQL 8** running locally via `make services` for e2e tests, with + the `crewli_test` database created via `make test-db-create` +- **PHP 8.2+ + composer** for the Laravel test server in e2e tests +- **`api/.env`** present with valid `APP_KEY` (e2e `globalSetup` + inherits this; only `DB_DATABASE` is overridden to `crewli_test` on + the command line) + +### Known risks + +- **`unpkg.com` dependency** — the prototype HTML loads React + Babel + from unpkg.com via `