import { vi } from 'vitest' import { defineComponent, h, type Component } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { RouterView } from 'vue-router' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, useApi } from '@/composables/useApi' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { accessFor } from '@/test/access' export interface Call { method: string path: string query: URLSearchParams body: Record | undefined } export type Route = unknown | ((call: Call) => unknown | Promise) export const kit: { wrapper: VueWrapper | null; calls: Call[] } = { wrapper: null, calls: [] } export function failWith(status: number, body?: string): Route { return () => { throw new ApiError(status, undefined, body) } } export function deferred() { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } export const WRITE_FAILS = failWith(500, 'Internal server error') export function callsTo(method: string, path: string) { return kit.calls.filter((call) => call.method === method && call.path === path) } interface MountOptions { role?: string routes: Record width?: number userId?: number root?: Component } export async function mountRoute(url: string, options: MountOptions) { setViewportWidth(options.width ?? DESKTOP_WIDTH) Element.prototype.scrollIntoView = vi.fn() setActivePinia(createPinia()) kit.calls = [] const session = useSessionStore() session.session = { verified: true, username: 'marta', id: options.userId ?? 1, email: 'marta@renew.example', displayName: 'Marta Alves', access: accessFor(options.role ?? 'owner'), } session.status = 'ready' const request = vi.fn(async (path: string, init?: RequestInit) => { const [pathname = '', search = ''] = path.split('?') const call: Call = { method: init?.method ?? 'GET', path: pathname, query: new URLSearchParams(search), body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, } kit.calls.push(call) const key = `${call.method} ${pathname}` if (!(key in options.routes)) { throw new Error(`Unexpected request: ${key}`) } const route = options.routes[key] return typeof route === 'function' ? route(call) : structuredClone(route) }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }), request, requestPaged: vi.fn(), } as unknown as ReturnType) await router.push(url) const root = options.root ?? defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) }) kit.wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] } }) await settle() } export async function settle() { for (let round = 0; round < 6; round++) { await flushPromises() } } export async function unmountRoute() { kit.wrapper?.unmount() kit.wrapper = null await router.push('/login') document.body.innerHTML = '' } export function text() { return (document.body.textContent ?? '').replace(/\s+/g, ' ').trim() } export function dialogBox() { return document.body.querySelector('[role="dialog"], [role="alertdialog"]') } export function dialogText() { return (dialogBox()?.textContent ?? '').replace(/\s+/g, ' ').trim() } export function control(label: string | RegExp, root: ParentNode = document.body) { return Array.from( root.querySelectorAll('button, [role="menuitem"], a, [role="checkbox"]'), ).find((element) => { const name = (element.getAttribute('aria-label') || element.textContent || '') .replace(/\s+/g, ' ') .trim() return typeof label === 'string' ? name === label : label.test(name) }) } export async function press(label: string | RegExp, root: ParentNode = document.body) { const target = control(label, root) if (!target) { throw new Error(`No control named ${String(label)}`) } target.click() await settle() } export async function chooseAction(language: string, item: string) { await press(`Actions for ${language}`) await press(item) } export async function typeInto(element: HTMLInputElement, value: string) { element.value = value element.dispatchEvent(new Event('input', { bubbles: true })) await settle() } export function toastText() { return ( document.body.querySelector( '[data-slot="viewport"], [role="region"][aria-label*="otification"]', )?.textContent ?? '' ) }