Files
summercms/admin/tests/helpers.ts
Jakub Zych 1c2a66df45 test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
2026-09-27 17:53:57 +02:00

172 lines
5.8 KiB
TypeScript

// Shared smoke-test helpers: a fetch mock keyed by "METHOD /path" and an app
// mount at a route with a signed-in admin. Fixtures are neutral acme.demo.*
// data; no application names appear in framework tests.
import { vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import App from '../src/App.vue'
import { createAdminRouter } from '../src/app/router'
import { setBundle } from '../src/app/i18n'
import { clearUser, me } from '../src/state/useAuth'
import { clearRecordCrumb } from '../src/state/useBreadcrumbs'
import { loadNavigation, setNavigation } from '../src/state/useNavigation'
import { setSettings } from '../src/state/useSettings'
import { watchViewport } from '../src/state/useSidebar'
import { clearToasts } from '../src/state/useToasts'
import navigation from './fixtures/navigation.json'
import { langFixture } from './fixtures/typed'
export const API = '/admin-test/api/v1'
export const profile = {
data: {
id: 7,
login: 'dev',
email: 'dev@example.test',
first_name: 'Dana',
last_name: 'Dev',
is_superuser: false,
role: { id: 2, code: 'developer', name: 'Developer' },
},
meta: {},
}
export interface Reply {
status?: number
body: unknown
}
export type Route = Reply | ((request: Request) => Reply | Promise<Reply>)
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
export const notFound = { error: { code: 'not_found', message: 'Not found', details: {} } }
/** Replaces fetch; routes are keyed by "METHOD pathname" (query ignored). */
export function mockApi(routes: Record<string, Route>): Request[] {
const calls: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
calls.push(request)
const route = routes[`${request.method} ${new URL(request.url).pathname}`]
if (!route) {
return json(404, notFound)
}
const reply = typeof route === 'function' ? await route(request.clone()) : route
return json(reply.status ?? 200, reply.body)
})
return calls
}
export function pathOf(request: Request): string {
return new URL(request.url).pathname
}
export function queryOf(request: Request): URLSearchParams {
return new URL(request.url).searchParams
}
/** Requests matching a method and pathname, in call order. */
export function requestsTo(calls: Request[], method: string, pathname: string): Request[] {
return calls.filter((request) => request.method === method && pathOf(request) === pathname)
}
/** Mounts the whole app at a path with the profile and navigation loaded. */
export async function mountApp(path: string, routes: Record<string, Route>, options: { attach?: boolean } = {}) {
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`GET ${API}/navigation`]: { body: navigation },
...routes,
})
await me()
await loadNavigation()
const router = createAdminRouter(createMemoryHistory())
await router.push(path)
const wrapper = mount(App, {
global: { plugins: [router] },
attachTo: options.attach ? document.body : undefined,
})
await flushPromises()
return { wrapper, calls, router }
}
/** Signs the profile in over the mocked API; extra routes join the mock. */
export async function signIn(routes: Record<string, Route> = {}): Promise<Request[]> {
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: profile }, ...routes })
await me()
return calls
}
/** The real admin router on memory history, already at path. */
export async function routerAt(path: string) {
const router = createAdminRouter(createMemoryHistory())
await router.push(path)
await router.isReady()
return router
}
/** Dispatches a bubbling, cancelable keydown and returns the event. */
export function keydown(target: Element, key: string): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
target.dispatchEvent(event)
return event
}
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type MediaListener = (event: MediaQueryListEvent) => void
/** A matchMedia whose answers the test controls, with change events. */
export function fakeMedia(initial: Record<string, boolean>) {
const state = { ...initial }
const listeners = new Map<string, Set<MediaListener>>()
const of = (query: string): Set<MediaListener> => {
let set = listeners.get(query)
if (!set) {
set = new Set()
listeners.set(query, set)
}
return set
}
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
get matches() {
return state[query] ?? false
},
media: query,
onchange: null,
addEventListener: (_type: string, listener: MediaListener) => of(query).add(listener),
removeEventListener: (_type: string, listener: MediaListener) => of(query).delete(listener),
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
)
return {
change(query: string, matches: boolean): void {
state[query] = matches
for (const listener of of(query)) {
listener({ matches, media: query } as MediaQueryListEvent)
}
},
listeners: (query: string) => of(query).size,
}
}
/** Module state back to a signed-out, Polish-strings start. */
export function resetState(): void {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
clearRecordCrumb()
setBundle(langFixture.data, 'pl')
window.localStorage.clear()
// Re-reads the (cleared) preference and the desktop viewport of setup.ts,
// so a narrow or collapsed sidebar never leaks into the next test.
watchViewport()()
}