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 hide: ReturnType } 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 /** Initial Pinia store state (per-store map, see @pinia/testing docs). */ initialState?: Record> /** 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 /** Slots for mount(). */ slots?: Record /** Optional global stubs. */ stubs?: Record } 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: '
' } }, { path: '/:pathMatch(.*)*', component: { template: '
' } }], 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 }).__routerReady = navigatePromise return { wrapper, router, pinia, queryClient, notificationMock } }