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}