test(infra): mountWithProviders helper + Vuetify CT sanity test

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 14:56:48 +02:00
parent b8d18e63af
commit 82af11754a
4 changed files with 200 additions and 2 deletions

View File

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

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