test: add mountWithVuexy helper, install axe-core, segment vitest configs

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>
This commit is contained in:
2026-05-09 03:27:31 +02:00
parent b7d814ad85
commit 5f135ec2b9
6 changed files with 728 additions and 31 deletions

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { mountWithVuexy } from '../utils/mountWithVuexy'
describe('mountWithVuexy harness', () => {
it('mounts a trivial component with the full Vuexy stack', () => {
const Trivial = defineComponent({
setup() {
return () => h('div', { 'data-test': 'ok' }, 'hello')
},
})
const { wrapper, queryClient, pinia, router, notificationMock } = mountWithVuexy(Trivial)
expect(wrapper.find('[data-test="ok"]').text()).toBe('hello')
expect(queryClient).toBeDefined()
expect(pinia).toBeDefined()
expect(router).toBeDefined()
expect(notificationMock.show).toBeTypeOf('function')
})
it('loads the timetable CSS token sheet so var(--tt-…) resolves on :root', () => {
const Probe = defineComponent({
setup() {
return () => h('div', { id: 'probe' })
},
})
mountWithVuexy(Probe)
// The CSS file is imported at module load time inside mountWithVuexy.
// Resolving against documentElement (=:root) avoids ambiguity around
// jsdom's default style-cascade behaviour on arbitrary elements.
const value = getComputedStyle(document.documentElement).getPropertyValue('--tt-status-confirmed-bg').trim()
expect(value).toBe('#e8f8f0')
})
})

View File

@@ -0,0 +1,55 @@
import 'vitest-axe/extend-expect'
import { expect } from 'vitest'
import * as matchers from 'vitest-axe/matchers'
// Register vitest-axe's `toHaveNoViolations` matcher so a11y tests can call
// `expect(await axe(node)).toHaveNoViolations()`.
expect.extend(matchers)
// Deterministic crypto polyfill (mirrors tests/setup.ts) so generateIdempotencyKey()
// returns a stable value across component-test runs without bringing in the
// router mock from tests/setup.ts.
if (!globalThis.crypto) {
;(globalThis as { crypto: Crypto }).crypto = {
randomUUID: () => '00000000-0000-4000-8000-000000000000',
getRandomValues: (buf: Uint8Array) => {
for (let i = 0; i < buf.length; i++) buf[i] = 0
return buf
},
} as unknown as Crypto
}
// JSDOM's `Element.scrollIntoView` is not implemented by default; Vuetify's
// list/menu components call it during opening transitions. Stub it so the
// test environment doesn't throw.
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
Element.prototype.scrollIntoView = () => undefined
// JSDOM's `getBoundingClientRect` returns zeros, which is fine for most
// assertions but breaks Vuetify positioning math in some menus. Provide a
// minimal viewport size on document body so anchored components can render.
if (typeof document !== 'undefined') {
Object.defineProperty(document.body, 'getBoundingClientRect', {
configurable: true,
value: () => ({ top: 0, left: 0, right: 1024, bottom: 768, width: 1024, height: 768, x: 0, y: 0, toJSON: () => ({}) }),
})
}
// `window.visualViewport` is consulted by Vuetify; happy-dom has it but
// jsdom does not. Stub the minimum surface the lib reads.
if (typeof window !== 'undefined' && !window.visualViewport) {
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: { width: 1024, height: 768, offsetLeft: 0, offsetTop: 0, scale: 1, addEventListener: () => undefined, removeEventListener: () => undefined },
})
}
// `ResizeObserver` is required by Vuetify VOverlay and friends; jsdom lacks it.
if (typeof globalThis.ResizeObserver === 'undefined') {
;(globalThis as { ResizeObserver: unknown }).ResizeObserver = class ResizeObserver {
observe(): void { /* noop */ }
unobserve(): void { /* noop */ }
disconnect(): void { /* noop */ }
}
}

View File

@@ -0,0 +1,180 @@
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 }
}