chore(test-infra): TEST-INFRA-001 — Playwright + visual regression + real-backend e2e foundation #21
@@ -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'
|
||||
|
||||
@@ -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