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:
@@ -10,8 +10,13 @@ import { type ThemeDefinition, createVuetify } from 'vuetify'
|
|||||||
import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports
|
import * as components from 'vuetify/components' // eslint-disable-line no-restricted-imports
|
||||||
import * as directives from 'vuetify/directives'
|
import * as directives from 'vuetify/directives'
|
||||||
|
|
||||||
// Plain-CSS token sheet — JSDOM evaluates :root custom properties from
|
// Vuetify base styles — required for v-btn / v-chip / v-card etc. to
|
||||||
// this import so getComputedStyle(el).getPropertyValue('--tt-status-…')
|
// 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
|
// resolves during component tests. Path resolved by the alias map in
|
||||||
// playwright-ct.config.ts.
|
// playwright-ct.config.ts.
|
||||||
import '@/styles/tokens/_timetable.css'
|
import '@/styles/tokens/_timetable.css'
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
123
apps/app/tests/playwright-ct/utils/mountWithProviders.ts
Normal file
123
apps/app/tests/playwright-ct/utils/mountWithProviders.ts
Normal 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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user