Files
crewli/apps/app/tests/playwright-ct/utils/mountWithProviders.ts
bert.hausmans 82af11754a 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>
2026-05-10 14:56:48 +02:00

124 lines
5.1 KiB
TypeScript

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