- 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
172 lines
5.8 KiB
TypeScript
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()()
|
|
}
|