160 lines
4.6 KiB
TypeScript
160 lines
4.6 KiB
TypeScript
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<string, unknown> | undefined
|
|
}
|
|
|
|
export type Route = unknown | ((call: Call) => unknown | Promise<unknown>)
|
|
|
|
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<T>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((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<string, Route>
|
|
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<typeof useApi>)
|
|
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<HTMLElement>('[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<HTMLElement>('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 ?? ''
|
|
)
|
|
}
|