Foundation for the upcoming component / integration / a11y tests.
vitest.config.ts now declares two projects:
- "unit" — pure-logic tests under tests/unit/, src/**/__tests__/,
and tests/*.spec.ts (the legacy sanity test).
happy-dom, no Vuetify, fast path.
- "component" — tests under tests/component/, tests/integration/,
tests/a11y/. jsdom, Vuetify inlined via SSR noExternal,
CSS imports processed (so :root token sheet loads), and
no global vue-router mock so the real router can run.
Both share the same alias map and AutoImport bag.
tests/utils/mountWithVuexy.ts (new):
- Real Vuetify with the Crewli theme tokens
- createTestingPinia (actions execute by default; stubActions opt-in)
- vue-router with memory history at the configured initialPath + ?query
- Fresh QueryClient per call (zero cross-test cache leak)
- Notification mock injected via Pinia plugin so any useNotificationStore()
resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual
NotificationStore API surface (per Phase A finding A4)
- Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves
var(--tt-…) when components call getComputedStyle()
tests/setup.component.ts (new):
- vitest-axe matcher registration
- JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body
bounding rect — Vuetify menus / overlays would crash without them
- Deterministic crypto polyfill (mirrors tests/setup.ts so
generateIdempotencyKey() is stable, but without the router mock)
tests/component/_smoke.test.ts (new):
- Mounts a trivial component → asserts wrapper, queryClient, pinia,
router, notificationMock all populated
- Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg')
→ asserts '#e8f8f0' (proves the CSS token sheet really loaded)
devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing.
Total: 319 → 321 tests; 42 → 43 files. Both projects green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
import { type VueWrapper, mount } from '@vue/test-utils'
|
|
import { createTestingPinia } from '@pinia/testing'
|
|
import type { TestingPinia } from '@pinia/testing'
|
|
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
|
import { type RouteRecordRaw, type Router, createMemoryHistory, createRouter } from 'vue-router'
|
|
import { type ThemeDefinition, createVuetify } from 'vuetify'
|
|
import * as components from 'vuetify/components'
|
|
import * as directives from 'vuetify/directives'
|
|
import { vi } from 'vitest'
|
|
import type { Component } from 'vue'
|
|
|
|
// Plain-CSS token sheet — JSDOM evaluates :root custom properties from this
|
|
// import so getComputedStyle(el).getPropertyValue('--tt-status-…') resolves
|
|
// during component tests. Path resolved by vitest.config alias `@`.
|
|
import '@/styles/tokens/_timetable.css'
|
|
|
|
/**
|
|
* Notification mock matching the actual store API:
|
|
* useNotificationStore().show(message, type, duration)
|
|
* (See apps/app/src/stores/useNotificationStore.ts)
|
|
*/
|
|
export interface NotificationMock {
|
|
show: ReturnType<typeof vi.fn>
|
|
hide: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
export function createNotificationMock(): NotificationMock {
|
|
return {
|
|
show: vi.fn(),
|
|
hide: vi.fn(),
|
|
}
|
|
}
|
|
|
|
export interface MountWithVuexyOptions {
|
|
|
|
/** Routes to register on the test router. Default: a single catch-all. */
|
|
routes?: RouteRecordRaw[]
|
|
|
|
/** Initial path the router opens at. Default: '/'. */
|
|
initialPath?: string
|
|
|
|
/** Initial query string params. */
|
|
initialQuery?: Record<string, string>
|
|
|
|
/** Initial Pinia store state (per-store map, see @pinia/testing docs). */
|
|
initialState?: Record<string, Record<string, unknown>>
|
|
|
|
/** Override the default fresh QueryClient (useful for prefilled caches). */
|
|
queryClient?: QueryClient
|
|
|
|
/** Provide a custom notification mock; default `createNotificationMock()`. */
|
|
notificationMock?: NotificationMock
|
|
|
|
/**
|
|
* Set to `true` to use createTestingPinia's default action stubbing (every
|
|
* action becomes a vi.fn that does nothing). Default `false` — actions
|
|
* still execute so component tests exercise real store behaviour.
|
|
*/
|
|
stubActions?: boolean
|
|
|
|
/** props forwarded to mount(). */
|
|
props?: Record<string, unknown>
|
|
|
|
/** Slots for mount(). */
|
|
slots?: Record<string, unknown>
|
|
|
|
/** Optional global stubs. */
|
|
stubs?: Record<string, Component | boolean>
|
|
}
|
|
|
|
export interface MountWithVuexyResult {
|
|
wrapper: VueWrapper
|
|
router: Router
|
|
pinia: TestingPinia
|
|
queryClient: QueryClient
|
|
notificationMock: NotificationMock
|
|
}
|
|
|
|
const defaultTheme: ThemeDefinition = {
|
|
dark: false,
|
|
colors: {
|
|
primary: '#1f7ad1',
|
|
error: '#d63d4b',
|
|
success: '#2fa66a',
|
|
warning: '#e0992c',
|
|
info: '#1f7ad1',
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Mounts a Vue component with the full Vuexy/Vuetify stack wired up:
|
|
* - Vuetify (real components + directives, default theme tokens)
|
|
* - Pinia (createTestingPinia — actions execute by default)
|
|
* - TanStack Vue Query (a fresh QueryClient per call — never shared)
|
|
* - Vue Router (memory history, opens at `initialPath` with `initialQuery`)
|
|
* - Notification store mocked at the Pinia layer
|
|
*
|
|
* Each call gets fresh instances of router, pinia, and queryClient — no
|
|
* cross-test leakage. The notification mock is exposed so tests can assert
|
|
* `expect(notificationMock.show).toHaveBeenCalledWith('…', 'error', …)`.
|
|
*/
|
|
export function mountWithVuexy(component: Component, options: MountWithVuexyOptions = {}): MountWithVuexyResult {
|
|
const {
|
|
routes = [{ path: '/', component: { template: '<div />' } }, { path: '/:pathMatch(.*)*', component: { template: '<div />' } }],
|
|
initialPath = '/',
|
|
initialQuery,
|
|
initialState = {},
|
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }),
|
|
notificationMock = createNotificationMock(),
|
|
stubActions = false,
|
|
props,
|
|
slots,
|
|
stubs,
|
|
} = options
|
|
|
|
const router = createRouter({ history: createMemoryHistory(), routes })
|
|
|
|
// Patch the notification store via initialState so any useNotificationStore()
|
|
// call resolves to the mock fns. Pinia testing replaces actions when
|
|
// stubActions=true; here we override the action surface explicitly so the
|
|
// mock is consistent regardless of stubActions.
|
|
const pinia = createTestingPinia({
|
|
stubActions,
|
|
initialState: {
|
|
...initialState,
|
|
notification: {
|
|
visible: false,
|
|
message: '',
|
|
type: 'info',
|
|
timeout: 5000,
|
|
...(initialState.notification ?? {}),
|
|
},
|
|
},
|
|
createSpy: vi.fn,
|
|
})
|
|
|
|
// Bind the notification action mocks into the store. We do this AFTER
|
|
// createTestingPinia so the store is registered.
|
|
pinia.use(({ store }) => {
|
|
if (store.$id === 'notification') {
|
|
store.show = notificationMock.show
|
|
store.hide = notificationMock.hide
|
|
}
|
|
})
|
|
|
|
const vuetify = createVuetify({
|
|
components,
|
|
directives,
|
|
theme: { defaultTheme: 'crewliLight', themes: { crewliLight: defaultTheme } },
|
|
})
|
|
|
|
const navigatePromise = (async () => {
|
|
if (initialQuery)
|
|
await router.push({ path: initialPath, query: initialQuery })
|
|
else
|
|
await router.push(initialPath)
|
|
await router.isReady()
|
|
})()
|
|
|
|
const wrapper = mount(component, {
|
|
props,
|
|
slots,
|
|
global: {
|
|
plugins: [
|
|
vuetify,
|
|
pinia,
|
|
router,
|
|
[VueQueryPlugin, { queryClient }],
|
|
],
|
|
stubs,
|
|
},
|
|
})
|
|
|
|
// The router push above is fire-and-forget; consumers that need the route
|
|
// to be settled before the first assertion should `await wrapper.vm.$nextTick()`
|
|
// a couple of times after mount. We attach the promise so tests can await it.
|
|
;(wrapper as unknown as { __routerReady: Promise<void> }).__routerReady = navigatePromise
|
|
|
|
return { wrapper, router, pinia, queryClient, notificationMock }
|
|
}
|