From 82af11754a93a979badbfa390034bb9c633c6513 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 14:56:48 +0200 Subject: [PATCH] test(infra): mountWithProviders helper + Vuetify CT sanity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add tests/playwright-ct/utils/mountWithProviders.ts: ergonomic wrapper around Playwright CT's mount() exposing buildMountArgs() and readNotificationState(). Documents the Vue Test Utils ↔ Playwright CT API divergence (provider plugins must be wired in beforeMount, not at call time) and the Vuetify-temp lifecycle (replaced by PrimeVue in F3). - Add tests/playwright-ct/components/SanityButtonHarness.vue: a v-btn harness with a click counter; lives in a .vue file so Vite bundles its CSS-side-effect imports for the browser context (Playwright CT runs the test orchestrator in Node and components in a Vite-bundled browser, unlike Vitest's single jsdom graph). - Add tests/playwright-ct/components/sanity-vuetify.spec.ts: two tests proving (a) v-btn renders and propagates clicks, (b) the --v-theme-primary CSS variable resolves to a parseable RGB triplet. - Update playwright/index.ts: import 'vuetify/styles' so the v-btn renders with its actual visual appearance (not unstyled). Required for B3's visual baselines. 3 component tests pass. 402 Vitest tests still pass unchanged. Lint + typecheck clean on new files. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/playwright/index.ts | 9 +- .../components/SanityButtonHarness.vue | 20 +++ .../components/sanity-vuetify.spec.ts | 50 +++++++ .../playwright-ct/utils/mountWithProviders.ts | 123 ++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 apps/app/tests/playwright-ct/components/SanityButtonHarness.vue create mode 100644 apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts create mode 100644 apps/app/tests/playwright-ct/utils/mountWithProviders.ts diff --git a/apps/app/playwright/index.ts b/apps/app/playwright/index.ts index 3a85b0f1..5ad543fa 100644 --- a/apps/app/playwright/index.ts +++ b/apps/app/playwright/index.ts @@ -10,8 +10,13 @@ import { type ThemeDefinition, createVuetify } from 'vuetify' import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports import * as directives from 'vuetify/directives' -// Plain-CSS token sheet — JSDOM evaluates :root custom properties from -// this import so getComputedStyle(el).getPropertyValue('--tt-status-…') +// Vuetify base styles — required for v-btn / v-chip / v-card etc. to +// render with their actual visual appearance (not a default browser +// look). Without this, Playwright visual baselines would capture +// unstyled components. Removed in F3 alongside the Vuetify plugin. +import 'vuetify/styles' + +// Plain-CSS token sheet — getComputedStyle(el).getPropertyValue('--tt-status-…') // resolves during component tests. Path resolved by the alias map in // playwright-ct.config.ts. import '@/styles/tokens/_timetable.css' diff --git a/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue new file mode 100644 index 00000000..17f792ab --- /dev/null +++ b/apps/app/tests/playwright-ct/components/SanityButtonHarness.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts new file mode 100644 index 00000000..35f7ccec --- /dev/null +++ b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import SanityButtonHarness from './SanityButtonHarness.vue' + +// B2 sanity — proves the full provider stack from playwright/index.ts +// is wired: +// - Vuetify renders v-btn with theme tokens applied +// - Click events propagate via Vue's reactivity (counter ref updates) +// - Vuetify CSS variables resolve in computed style +// +// TEMPORARY VUETIFY: this test is replaced by a PrimeVue equivalent +// in F3. Do not extend or generalise — F3 rewrites it. See +// dev-docs/ARCH-TESTING.md §6. +// +// Why a .vue harness file: Playwright CT runs the test orchestrator +// in Node and the component in a Vite-bundled browser context. Vue +// components that pull in CSS-side-effect imports (Vuetify) cannot be +// loaded directly into the test's Node module graph; they must live +// in a .vue / Vite-compilable file. This is a structural divergence +// from Vitest, which uses jsdom and one module graph for both. + +test.describe('B2 sanity: provider stack', () => { + test('mounts a Vuetify v-btn and propagates clicks', async ({ mount }) => { + const component = await mount(SanityButtonHarness) + const btn = component.locator('[data-test="btn"]') + + await expect(btn).toBeVisible() + await expect(btn).toContainText('Clicks: 0') + + await btn.click() + await expect(btn).toContainText('Clicks: 1') + + await btn.click() + await expect(btn).toContainText('Clicks: 2') + }) + + test('Vuetify primary theme color resolves on rendered button', async ({ mount, page }) => { + await mount(SanityButtonHarness) + + // Vuetify exposes theme primary as "R, G, B" decimals on + // --v-theme-primary (e.g. "31, 122, 209" for #1f7ad1). + const themePrimary = await page.evaluate(() => { + const root = document.documentElement + + return getComputedStyle(root).getPropertyValue('--v-theme-primary').trim() + }) + + expect(themePrimary).not.toBe('') + expect(themePrimary.split(',')).toHaveLength(3) + }) +}) diff --git a/apps/app/tests/playwright-ct/utils/mountWithProviders.ts b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts new file mode 100644 index 00000000..8aabd5ba --- /dev/null +++ b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts @@ -0,0 +1,123 @@ +import type { Locator } from '@playwright/test' +import type { Component } from 'vue' + +import type { HooksConfig } from '../../../playwright' + +/** + * mountWithProviders — Playwright Component Testing analogue of + * tests/utils/mountWithVuexy.ts. + * + * Why two helpers? + * ---------------- + * Playwright CT's `mount()` API is structurally different from + * @vue/test-utils' `mount()`: provider plugins (Vuetify, Pinia, + * Router, VueQuery) must be registered in `playwright/index.ts`'s + * `beforeMount` hook rather than passed at call time. This helper + * is a thin ergonomic wrapper that: + * + * - Forwards `hooksConfig` to the beforeMount hook (typed via + * HooksConfig from playwright/index.ts) + * - Returns the standard Playwright Locator from CT's mount, so + * downstream test code uses normal Playwright assertions + * (component.click(), expect(component).toBeVisible(), etc.) + * - Serves as a single, discoverable surface for "how do I mount + * a component in this codebase" questions + * + * Lifecycle note (TEMPORARY VUETIFY): + * ----------------------------------- + * The Vuetify plugin line in `playwright/index.ts`'s `beforeMount` + * hook is INTENTIONALLY temporary state. F3 (PrimeVue foundation, + * RFC-WS-FRONTEND-PRIMEVUE §6) replaces it with PrimeVue. We do NOT + * abstract behind a "pluggable UI framework" indirection because: + * + * 1. We are NOT retaining Vuetify; the abstraction would itself + * need to be removed in F3. + * 2. The swap is mechanical (~2-hour) and atomic; abstraction adds + * cognitive cost without paying back. + * 3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration + * sprint" should read this JSDoc and dev-docs/ARCH-TESTING.md §6 + * for context. + * + * Equivalence to mountWithVuexy.ts: + * --------------------------------- + * | Capability | Vitest (mountWithVuexy) | Playwright CT (this) | + * |----------------------------------|-------------------------------|----------------------| + * | Vuetify w/ tokens | createVuetify({components,…}) | beforeMount hook | + * | Pinia (actions execute) | createTestingPinia | createPinia | + * | TanStack Query (fresh client) | per-call new QueryClient | per-test in hook | + * | Memory-history router | per-call createRouter | per-test in hook | + * | initialPath / initialQuery | options.initialPath | hooksConfig.… | + * | Initial Pinia state | options.initialState | hooksConfig.piniaInitialState | + * | Notification mock | createNotificationMock + plug | (see assertNotification below) | + * + * Notification assertions: + * ------------------------ + * Playwright runs in a separate Node process from the browser and + * cannot use `vi.fn()` spies on store actions. Instead, tests assert + * on the rendered UI (e.g. `await expect(page.getByRole('alert')) + * .toContainText('Saved')`) or read pinia state via page.evaluate. + * This is a deliberate divergence from the Vitest pattern — UI + * assertions are stronger than spy assertions for a real-browser + * runner. + * + * Type signature constraint: + * -------------------------- + * Playwright CT's MountResult uses internal types. We accept the + * native MountOptions shape and wrap; tests should import the + * Playwright-CT `expect`/`test` and call `mountWithProviders` to + * get the locator back. + */ +export interface MountOptions { + props?: Record + slots?: Record + hooksConfig?: HooksConfig +} + +// Re-export so callers have one import surface. +export type { HooksConfig } from '../../../playwright' + +// The actual mount call must be done by the test using Playwright CT's +// `mount` fixture (`test('…', async ({ mount }) => …)`). We expose a +// helper that builds the options object correctly. This avoids the +// "two ways to mount" footgun: there's the Playwright fixture, and +// there's our wrapper that produces its arguments. +export function buildMountArgs( + component: C, + opts: MountOptions = {}, +): { + component: C + options: { props?: Record; slots?: Record; hooksConfig?: HooksConfig } + } { + return { + component, + options: { + props: opts.props, + slots: opts.slots, + hooksConfig: opts.hooksConfig, + }, + } +} + +/** + * Convenience: assert on the notification store via the browser's + * window.__pinia exposed in the beforeMount hook. Returns the current + * notification state as serialised JSON. + */ +export async function readNotificationState( + componentLocator: Locator, +): Promise<{ visible: boolean; message: string; type: string }> { + const page = componentLocator.page() + + return page.evaluate(() => { + interface Win { __pinia?: { state: { value: Record } } } + const w = window as unknown as Win + + const state = w.__pinia?.state?.value?.notification as + | { visible: boolean; message: string; type: string } + | undefined + + return state + ? { visible: state.visible, message: state.message, type: state.type } + : { visible: false, message: '', type: 'info' } + }) +}