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' } + }) +}