Merge pull request 'chore(test-infra): TEST-INFRA-001 — Playwright + visual regression + real-backend e2e foundation' (#21) from chore/test-infra-001 into main

Reviewed-on: bert.hausmans/crewli#21
This commit was merged in pull request #21.
This commit is contained in:
2026-05-10 22:09:21 +02:00
30 changed files with 1934 additions and 20 deletions

View File

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

2
.gitattributes vendored Normal file
View File

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

12
.gitignore vendored
View File

@@ -39,6 +39,18 @@ 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/
# Playwright e2e seed-data fixture file — written by E2EBaselineSeeder
# during globalSetup, never source-of-truth.
api/storage/app/e2e-fixtures.json
# Misc
*.pem
.cache/

View File

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

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
/**
* Seeds a deterministic baseline for Playwright e2e tests.
*
* Creates:
* - Roles (org_admin etc. via RoleSeeder)
* - One user: e2e@test.local / password ("password")
* - One organisation, attached as org_admin
* - One event spanning today..+30d
* - One stage with one StageDay
* - One artist + engagement + performance (version=0)
*
* Used by tests/playwright-e2e/. Idempotency: this seeder assumes a
* `migrate:fresh` was just run, so it creates without checking for
* existing rows. Re-running on a non-empty DB would create duplicates.
*
* NOT used by PHPUnit PHPUnit uses factories per test class with
* RefreshDatabase. This is e2e-specific.
*/
final class E2EBaselineSeeder extends Seeder
{
public function run(): void
{
$this->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}");
}
}

View File

@@ -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",

View File

@@ -0,0 +1,78 @@
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'] },
},
],
// Auto-start the prototype static server for visual baselines.
// Tests under tests/playwright-ct/visual/ navigate to this URL via
// page.goto() rather than calling mount(); they coexist with normal
// CT tests in the same runner.
webServer: {
command: 'node tests/playwright-ct/visual/static-server.mjs',
url: `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
stdout: 'ignore',
stderr: 'pipe',
},
})

View File

@@ -0,0 +1,69 @@
import { defineConfig, devices } from '@playwright/test'
// 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',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: process.env.CI ? 'github' : 'list',
globalSetup: './tests/playwright-e2e/global-setup.ts',
use: {
baseURL: E2E_API_URL,
trace: 'off',
video: 'off',
screenshot: 'off',
viewport: { width: 1440, height: 900 },
extraHTTPHeaders: {
Accept: 'application/json',
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// 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: 'cd ../../api && DB_DATABASE=crewli_test php artisan --env=testing serve --port=8001',
url: `${E2E_API_URL}/up`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
stdout: 'ignore',
stderr: 'pipe',
},
})

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Playwright CT — Crewli</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
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'
// 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'
// =============================================================================
// HOOKS CONFIG (per-test, opt-in)
// =============================================================================
//
// Tests pass `hooksConfig` to mount() to override defaults. Shape:
// {
// initialRoute?: string,
// initialQuery?: Record<string, string>,
// routes?: RouteRecordRaw[],
// piniaInitialState?: Record<string, Record<string, unknown>>,
// // 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<string, string>
routes?: RouteRecordRaw[]
piniaInitialState?: Record<string, Record<string, unknown>>
}
const defaultTheme: ThemeDefinition = {
dark: false,
colors: {
primary: '#1f7ad1',
error: '#d63d4b',
success: '#2fa66a',
warning: '#e0992c',
info: '#1f7ad1',
},
}
beforeMount<HooksConfig>(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: '<div data-test="ct-route-root" />' } },
{ path: '/:pathMatch(.*)*', component: { template: '<div data-test="ct-route-catchall" />' } },
]
const router = createRouter({ history: createMemoryHistory(), routes })
app.use(router)
if (hooksConfig?.initialRoute) {
await router.push({
path: hooksConfig.initialRoute,
query: hooksConfig.initialQuery,
})
}
await router.isReady()
})

244
apps/app/pnpm-lock.yaml generated
View File

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

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function onClick() {
count.value += 1
}
</script>
<template>
<div data-test="harness">
<VBtn
color="primary"
data-test="btn"
@click="onClick"
>
Clicks: {{ count }}
</VBtn>
</div>
</template>

View File

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

View File

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

View File

@@ -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<string, unknown>
slots?: Record<string, unknown>
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<C extends Component>(
component: C,
opts: MountOptions = {},
): {
component: C
options: { props?: Record<string, unknown>; slots?: Record<string, unknown>; 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<string, unknown> } } }
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' }
})
}

View File

@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/experimental-ct-vue'
const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`
test('prototype loads and renders', async ({ page }) => {
await page.goto(PROTOTYPE_URL)
// Wait for the React app to render. The first stage row appears
// once data + babel-transformed JSX are loaded.
await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 })
})

View File

@@ -0,0 +1,203 @@
import { expect, test } from '@playwright/experimental-ct-vue'
// =============================================================================
// VISUAL BASELINES — Artist Management surfaces (RFC §A.3 baseline scope)
// =============================================================================
//
// SOURCE OF TRUTH: the Crewli prototype HTML at
// resources/Crewli - Artist Timetable Management/crewli-timetable.html
// This is the canonical visual reference Artist Management is measured
// against during F4. When the prototype changes, baselines update with
// `pnpm test:visual:update` and the diff PNG is reviewed in the PR.
//
// Tagged @visual so `pnpm test:visual` runs only this suite.
//
// Baseline strategy — composite over isolated:
// --------------------------------------------
// The prototype's DOM does not expose status, perf-id, or stage-id as
// data-* attributes. Block status is encoded in inline `style.background`
// only. Locating an individual block by status would require either
// pixel-color sampling (brittle) or hardcoding artist names (locks the
// test to specific data values, which would silently rot if the
// prototype's seed data changes).
//
// Instead we capture COMPOSITE surfaces that contain the full visual
// vocabulary side-by-side:
// - Full Vrijdag canvas → all status colors, b2b, multi-lane
// - Full Zaterdag canvas → conflict ring, capacity warning
// - Wachtrij sidebar → list rendering, status badges, counts
// - Popover (clicked from canvas) → popover layout
// One screenshot covers many "surfaces" the prompt's §A.3 list
// enumerates separately. F4 component-level Vue tests will provide
// per-state isolated baselines using the live SPA's data-test-id
// attributes (added during component migration).
//
// Surfaces NOT captured here are documented as test.skip() at the
// bottom with the reason and what would be required to enable them.
// =============================================================================
const PROTOTYPE_URL = `http://127.0.0.1:${process.env.PROTOTYPE_PORT ?? 5179}/crewli-timetable.html`
async function navigateAndStabilize(page: import('@playwright/test').Page) {
await page.goto(PROTOTYPE_URL)
await expect(page.locator('.cw-block').first()).toBeVisible({ timeout: 15_000 })
// Hide elements that vary visually between machines or are dev-only.
await page.addStyleTag({
content: `
*:focus { outline: none !important; }
.cw-tweaks-panel, .cw-tweak-btn { visibility: hidden !important; }
`,
})
await page.evaluate(() => document.fonts.ready)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(undefined))))
}
test.describe('@visual Artist Management — prototype baselines', () => {
test.use({ viewport: { width: 1440, height: 900 } })
// ---------------------------------------------------------------------------
// CANVAS — full-page baselines per day
//
// Vrijdag (d_fr) covers status colors (option, requested, confirmed,
// contracted, concept), B2B indicators (auto-derived for adjacent
// perfs on hardstyle: p_1→p_2→p_3), and multi-lane stacking
// (p_4 / p_4b / p_4c overlap).
//
// Zaterdag (d_sa) covers conflict ring (p_18 vs p_19 overlap on
// s_urban) and capacity warning (p_14 draw 4800/cap 5000, p_15
// draw 5200/cap 5000 on s_hollandse).
// ---------------------------------------------------------------------------
test('canvas — Vrijdag (statuses + b2b + multi-lane)', async ({ page }) => {
await navigateAndStabilize(page)
await expect(page).toHaveScreenshot('canvas-friday.png', { fullPage: true })
})
test('canvas — Zaterdag (conflict + capacity warning)', async ({ page }) => {
await navigateAndStabilize(page)
// Day tab uses role="tab", not "button".
await page.getByRole('tab', { name: /Zaterdag/ }).click()
await page.waitForTimeout(200)
await expect(page).toHaveScreenshot('canvas-saturday.png', { fullPage: true })
})
// ---------------------------------------------------------------------------
// STAGE ROW — first row in isolation, captures multi-lane stacking
// detail. The prototype renders rows as .cw-tt-stage (label column)
// + a corresponding lane-band in the canvas. We capture the canvas
// element which spans 1440×row-height.
// ---------------------------------------------------------------------------
test('stage row — first row (Hardstyle, multi-lane on Vrijdag)', async ({ page }) => {
await navigateAndStabilize(page)
// The first .cw-tt-stage is hardstyle on Vrijdag. We capture the
// full row width by screenshotting the .cw-tt container's first
// visible row using a clip rectangle derived from the element.
const firstStage = page.locator('.cw-tt-stage').first()
await expect(firstStage).toBeVisible()
const stageBox = await firstStage.boundingBox()
if (!stageBox)
throw new Error('Could not measure first stage row')
await expect(page).toHaveScreenshot('stage-row-multilane.png', {
clip: { x: 0, y: stageBox.y, width: 1440, height: stageBox.height },
})
})
// ---------------------------------------------------------------------------
// WACHTRIJ — populated state (default seed data has parked + pending)
// ---------------------------------------------------------------------------
test('wachtrij — populated (default seed)', async ({ page }) => {
await navigateAndStabilize(page)
const queue = page.locator('aside.cw-parking').first()
await expect(queue).toBeVisible()
await expect(queue).toHaveScreenshot('wachtrij-populated.png')
})
// ---------------------------------------------------------------------------
// POPOVER — opens on block click
// ---------------------------------------------------------------------------
test('popover — opens on first-block click', async ({ page }) => {
await navigateAndStabilize(page)
// Click any visible block; the popover renders into a portal
// sibling of the block. We pick .cw-block first so we don't depend
// on artist names.
await page.locator('.cw-block').first().click()
const popover = page.locator('.cw-popover').first()
await expect(popover).toBeVisible({ timeout: 5_000 })
await expect(popover).toHaveScreenshot('popover.png')
})
// ---------------------------------------------------------------------------
// SKIPPED — surfaces not capturable from the canonical prototype.
// Each documents the gap so F4 component-level baselines pick them up.
// ---------------------------------------------------------------------------
test.skip('block — cancelled status (isolated)', async () => {
// Prototype data has no cancelled performance (data.js shows only
// concept|requested|option|confirmed|contracted appear). Status
// filter at timetable.jsx:227 also defaults cancelled OFF. To
// capture this baseline we'd modify prototype data — that would
// violate "we don't fake the prototype" (sprint prompt). Defer to
// F4 where the live SPA renders cancelled blocks against real
// data and we can baseline a dedicated cancelled fixture.
})
test.skip('block — B2B indicator (isolated)', async () => {
// Covered by canvas-friday.png composite (p_1→p_2→p_3 are
// perfectly back-to-back on hardstyle). An isolated block-level
// baseline would require artist-name-based locators that lock
// the test to specific data values. Defer to F4 where Vue
// components expose data-test-id="performance-block".
})
test.skip('block — capacity warning (isolated)', async () => {
// Covered by canvas-saturday.png composite. Same locator
// brittleness rationale as B2B above.
})
test.skip('block — conflict ring (isolated)', async () => {
// Covered by canvas-saturday.png composite.
})
test.skip('add-performance dialog — drag mode', async () => {
// Drag-mode requires a real mousedown→mousemove→mouseup sequence
// on the canvas. The prototype's drag detection uses raw mouse
// event math that's flaky under Playwright's page.mouse simulated
// events. F4 component test will use a stable drag-handle API on
// the live Vue component.
})
test.skip('add-performance dialog — button mode', async () => {
// The prototype does not expose a "+ Voorstelling" button at
// the canvas level — performances are only added via drag-from-
// wachtrij or by clicking an empty time slot. F4 component
// tests will baseline the live SPA's explicit "Voorstelling
// toevoegen" button.
})
test.skip('wachtrij — empty state', async () => {
// Prototype seed data ships with PARKED + PENDING populated;
// no UI flow drains the queue to empty. Defer to F4 component
// test against an empty-state fixture.
})
test.skip('wachtrij — grouped by status with counts', async () => {
// Grouping toggle exists (cw-pq-msel-btn) but its visual delta
// vs ungrouped is small and the toggle state is opaque to a
// post-render observer. Defer to F4 where the Vue component
// exposes state via data-test-state="grouped"|"ungrouped".
})
test.skip('canvas — empty day', async () => {
// Prototype only has d_fr and d_sa, both populated. No empty-day
// baseline available from canonical data. Defer to F4.
})
})

View File

@@ -0,0 +1,72 @@
/* eslint-disable no-console */
import { createReadStream, statSync } from 'node:fs'
import http from 'node:http'
import path from 'node:path'
// Tiny static-file server that serves the canonical Crewli prototype
// HTML so Playwright visual baselines can render it in real Chromium.
//
// Why not http-server / serve / similar?
// --------------------------------------
// They'd add ~5 MB and another supply-chain hop for a 30-line problem.
// The prototype directory has 9 files; we serve them with mime types
// for .html, .css, .js, .jsx (text/babel handles via the HTML loader).
// Node's built-in http + fs is sufficient.
//
// Started by playwright-ct.config.ts via webServer config.
const ROOT = path.resolve(
process.cwd(),
'..',
'..',
'resources',
'Crewli - Artist Timetable Management',
)
const PORT = Number(process.env.PROTOTYPE_PORT ?? 5179)
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.jsx': 'text/babel; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
}
const server = http.createServer((req, res) => {
// Strip query / hash before path resolution.
const urlPath = (req.url ?? '/').split('?')[0].split('#')[0]
const safe = path.normalize(urlPath).replace(/^(\.\.[\\/])+/, '')
const filePath = path.join(ROOT, safe === '/' ? 'crewli-timetable.html' : safe)
// Reject path traversal.
if (!filePath.startsWith(ROOT)) {
res.statusCode = 403
res.end('Forbidden')
return
}
try {
const stat = statSync(filePath)
if (stat.isDirectory()) {
res.statusCode = 404
res.end('Not found')
return
}
const ext = path.extname(filePath).toLowerCase()
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
res.setHeader('Cache-Control', 'no-store')
createReadStream(filePath).pipe(res)
}
catch {
res.statusCode = 404
res.end(`Not found: ${urlPath}`)
}
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`[prototype-server] http://127.0.0.1:${PORT}/ -> ${ROOT}`)
})

View File

@@ -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<void> {
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<string, unknown>
console.log('[e2e setup] Fixtures ready:', Object.keys(fixtures).join(', '))
}

View File

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

View File

@@ -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<LoginResult> {
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,
}
}

View File

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

342
dev-docs/ARCH-TESTING.md Normal file
View File

@@ -0,0 +1,342 @@
# 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
*First match wins — stop at the first YES.*
```
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
```
Diffs are reviewed in PRs. Baselines live at:
```
apps/app/tests/playwright-ct/__screenshots__/visual/<spec-path>/<name>.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 24, 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 `<script src="https://unpkg.com/...">`. Local
network outage or unpkg CDN issues will flake B3 baselines. Mitigation
if it bites: vendor `react.umd.js` + `babel.min.js` into the
prototype directory. Defer until it actually breaks.
- **Test DB shared with PHPUnit** — `crewli_test` is used by both the
PHPUnit suite (transaction-rollback per test) and the e2e fixture
(migrate:fresh + seed once). Running them concurrently would
collide. Lifecycle assumes serial execution, which is the realistic
local-dev flow.
---
## 9. Deferred to BACKLOG
- **TEST-INFRA-002** — CI runner selection (Gitea Actions vs. GitHub
Actions decision), runner image with PHP+MySQL+Node+pnpm, caching
strategy, screenshot-diff artifact upload, label-gated nightly e2e.
- F4 isolated component-level visual baselines (replacing the
composite baselines in B3 with per-state baselines using stable
`data-test-id` attributes).
- Multi-context concurrent-edit e2e patterns — see TEST-INFRA-002 in BACKLOG.md
- Multi-browser (Firefox, WebKit) baselines — Linux+Chromium only
for v1 per RFC §A.5.
- Mobile viewport baselines — desktop 1440×900 only for v1.
- Soketi / WebSocket testing infrastructure when ART-15 lands.

View File

@@ -2304,7 +2304,28 @@ Removed both packages from `apps/app/package.json`, regenerated
**Refs:** Session 4 follow-up Step 1; `apps/app/src/components/timetable/AddPerformanceDialog.vue` and `apps/app/src/components/sections/CreateShiftDialog.vue` as canonical references.
### TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright Component Testing
### TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright Component Testing ✅ Resolved
**Status:** Closed in `chore/test-infra-001` (commits `b8d18e6`,
`82af117`, `f6509d9`, `2dfb1e8`). Sprint executed per
RFC-WS-FRONTEND-PRIMEVUE Amendment A-1.
**Resolution:** Playwright + axe-core installed; CT and e2e runners
configured; Git LFS enabled for screenshots; `mountWithProviders`
helper established; full provider stack (Vuetify [TEMPORARY: replaced
in F3], Pinia, TanStack Query, Memory-history Router) wired in
`apps/app/playwright/index.ts`'s `beforeMount` hook. Existing
402 Vitest+jsdom tests left unchanged per amendment §A.3 goal 5
(natural replacement during F4 component migration).
**Deviations from original:**
- **CI integration deferred** to TEST-INFRA-002 — no CI exists in repo
today. Sprint scope cut to "passes locally" per amendment A-1
acceptance terms.
- Provider plugins wired in `playwright/index.ts` rather than at mount
call time (Playwright CT API divergence from `@vue/test-utils`).
Documented in `mountWithProviders.ts` JSDoc and
`dev-docs/ARCH-TESTING.md` §6.
**Aanleiding:** Session 4 follow-up landed component-mount, integration,
keyboard a11y, and axe-core tests on Vitest + jsdom as a deliberate
@@ -2347,7 +2368,28 @@ helper (designed to translate cleanly into Playwright CT's `mount()` API).
---
### TEST-CONTRACT-001 — End-to-end 409 conflict contract test against running Laravel
### TEST-CONTRACT-001 — End-to-end 409 conflict contract test against running Laravel ✅ Resolved
**Status:** Closed in `chore/test-infra-001` commit `2dfb1e8` (B4 of
TEST-INFRA-001 sprint).
**Resolution:** `apps/app/tests/playwright-e2e/timetable/409-conflict.
spec.ts` runs against a real Laravel test server (`php artisan serve
--port=8001`) seeded by `api/database/seeders/E2EBaselineSeeder.php`
via Playwright's `globalSetup`. Test asserts first-move 200 and
second-move 409 with `errors.conflict: 'version_mismatch'`. The
schema-drift bug class that motivated the entry (timetable-
stabilization B5) is now caught end-to-end.
**Deviations from original:**
- **Single-context replay** instead of two-browser-context concurrent
edit. The 409 is server-determined by stored version, not by
session identity, so single-context replay is functionally
equivalent for contract validation. Multi-context concurrent-edit
test is documented in ARCH-TESTING.md §9 as deferred to F4.
- **UI rollback assertion** (popover toast, block snap-back) deferred
to F4 UI-driven e2e — out of scope for B4 contract test.
- CI integration deferred to TEST-INFRA-002.
**Aanleiding:** The 409 rollback path in `useTimetableMutations.move()`
is currently asserted against a mocked axios response shape (Session 4
@@ -2379,7 +2421,42 @@ contract-protection value per line of test code.
---
### TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock states
### TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock states ✅ Resolved
**Status:** Closed in `chore/test-infra-001` commit `f6509d9` (B3 of
TEST-INFRA-001 sprint).
**Resolution:** 5 composite baselines captured from the canonical
prototype at `resources/Crewli - Artist Timetable Management/
crewli-timetable.html` (note: actual filename, not "Crewli Timetable.
html" as referenced in the older Aanleiding section below). Tests
live in `apps/app/tests/playwright-ct/visual/prototype.spec.ts`,
PNGs at `apps/app/tests/playwright-ct/__screenshots__/visual/
prototype.spec.ts/`. Tracked via Git LFS.
| Baseline | 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, status badges, counts |
| `popover.png` | Block-click popover layout |
**Deviations from original 8-state-minimum scope:**
- Composite-over-isolated strategy. Prototype DOM exposes status only
via inline `style.background`, no `data-*` attributes. Isolated-
block locators by artist name would lock tests to specific seed
data. Composite captures yield the same visual vocabulary in fewer
more stable images. Documented in `dev-docs/ARCH-TESTING.md` §4.
- 9 surfaces from RFC §A.3's enumerated list documented as
`test.skip()` with gap reasons (cancelled status absent from
prototype data, drag-mode flaky under simulated pointer events,
empty-state surfaces unreachable from canonical seed). All
deferred to F4 isolated component-level baselines using stable
`data-test-id` attributes.
- Pixel tolerance `maxDiffPixelRatio: 0.001` (0.1%) per RFC §A.6.
- Linux+Chromium only per RFC §A.5; no Mac/Windows baselines.
- CI integration deferred to TEST-INFRA-002.
**Aanleiding:** Status badge colors, capacity icon presence, B2B dots,
conflict ring, and cascade-pulse animation are UX contracts encoded in
@@ -2421,6 +2498,67 @@ TEST-CONTRACT-001.
---
### TEST-INFRA-002 — CI integration for Playwright + visual + e2e
**Aanleiding:** TEST-INFRA-001 sprint scope was cut to "passes
locally" because no CI exists in this repo today. The test
infrastructure is operational on developer machines (Vitest 402,
Playwright CT smoke + sanity + 5 visual baselines, e2e 409
contract test) but no automated gate prevents drift over time.
**Wat:**
- **Decision:** Gitea Actions vs. GitHub Actions vs. self-hosted
runner. Determines runner image availability, secrets management,
and pipeline DSL. No deadline; surface when first review cycle
feels drift without automated tests.
- **Runner image** with PHP 8.2+ (composer, ext-bcmath, ext-zip),
MySQL 8 (or service container), Node v22, pnpm 10, Chromium for
Playwright. Probably layered on top of a stock `ubuntu-22.04`
image with explicit installs to keep image size predictable.
- **Caching strategy:**
- `pnpm-store` keyed on `pnpm-lock.yaml` hash
- `vendor/` keyed on `composer.lock` hash
- `~/.cache/ms-playwright` keyed on Playwright version
- `__screenshots__/` cached locally for diff base; fetched via
Git LFS on baseline tests
- **Pipeline jobs (suggested):**
1. `lint+typecheck` — pnpm lint, pnpm typecheck (fast lane)
2. `vitest` — pnpm test (fast lane, runs in parallel with #1)
3. `playwright-component` — pnpm test:component (medium lane,
after #1+#2 pass)
4. `playwright-visual` — pnpm test:visual (medium lane, parallel
with #3). Diff PNGs uploaded as artifacts on failure; PR
comment with diff summary if runner supports it.
5. `playwright-e2e` — pnpm test:e2e (slow lane). Likely **label-
gated** or **nightly only**, not on every PR. Requires MySQL
service + Laravel server; ~10× more expensive than CT.
6. `phpunit` — composer test (medium lane). Independent of #3-#5.
- **Screenshot-diff artifact upload** — failing visual tests upload
expected.png + actual.png + diff.png as job artifacts. PR comment
links to artifact download.
- **Branch protection** — pre-merge required: lint, typecheck,
vitest, playwright-component, playwright-visual, phpunit. e2e
optional gate.
- **E2E DB strategy in CI** — fresh `crewli_test` per workflow run
via `make test-db-create` then `migrate:fresh + seed` from
globalSetup. No state shared across runs (unlike local
development).
- Multi-browser-context e2e patterns for optimistic locking flows
with UI rollback validation in the second context (cut #4 from
TEST-INFRA-001 sprint)
**Trigger:** No explicit deadline. Surfaces when first review cycle
feels drift without automated tests, OR when first regression slips
through the local-only gates, OR when team scales beyond solo
maintainer.
**Refs:** RFC-WS-FRONTEND-PRIMEVUE Amendment A-1 §A.7 DoD-17/19
deferral; `chore/test-infra-001` sprint commits `b8d18e6`-`2dfb1e8`
(local infrastructure operational); `dev-docs/ARCH-TESTING.md` §5
(CI strategy stub).
---
### ART-S4-UX-PARITY — Timetable UX parity with prototype
**Aanleiding:** Manual browser testing after `fix/timetable-stabilization`

View File

@@ -17,6 +17,10 @@ post-commit:
run: bash .githooks/post-commit
pre-push:
# piped: true forces serial execution. Both sync-check and git-lfs
# read from stdin (git pipes the push refspec to pre-push hooks);
# default parallel execution deadlocks with two stdin readers.
piped: true
commands:
sync-check:
run: bash .githooks/pre-push
@@ -29,3 +33,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}