import { vi } from 'vitest' import { defineComponent, h } from 'vue' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi' import type { Author } from '@/composables/useAuthors' import type { Site } from '@/composables/useSites' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import AuthorsView from '../AuthorsView.vue' import SitesView from '../SitesView.vue' export interface Call { method: string path: string raw: string query: URLSearchParams body: Record | undefined } export type Responder = (call: Call) => unknown | Promise export interface Backend { sites: Site[] authors: Author[] users: unknown[] calls: Call[] responders: Record } export function sitesFixture(): Site[] { const base = { default_social_image_id: null, default_robots: 'index' as const, locale: null, title_template: '%title% | %site%', default_description: '', } return [ { ...base, id: 1, name: 'Renew', site_name: 'Renew', key: 'renew', base_url: 'https://renew.example', is_default: true, article_count: 268, }, { ...base, id: 2, name: 'Journal', site_name: 'Journal', key: 'journal', base_url: 'https://journal.example', is_default: false, article_count: 38, }, { ...base, id: 3, name: 'Projects', site_name: 'Projects', key: 'projects', base_url: 'https://projects.example', default_robots: 'noindex', is_default: false, article_count: 6, }, { ...base, id: 4, name: 'Archive', site_name: 'Archive', key: 'archive', base_url: 'https://archive.example', is_default: false, article_count: 0, }, ] } function author(overrides: Partial & Pick): Author { return { bio: '', avatar_id: null, website: null, social_links: [], user_id: null, is_linked: false, article_count: 0, ...overrides, } } export function authorsFixture(withEmails = true): Author[] { const list = [ author({ id: 2, name: 'Inès Duarte', slug: 'ines-duarte', bio: 'Site manager and restoration carpenter.', website: 'https://ines-duarte.example', social_links: [ { type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte', }, ], user_id: 3, is_linked: true, article_count: 9, user_email: 'ines.duarte@renew.example', }), author({ id: 7, name: 'Camille Roche', slug: 'camille-roche', bio: 'Freelance writer on lime and stone.', article_count: 3, }), author({ id: 9, name: 'Dr. Alexandra Konstantinopoulou-Whitfield', slug: 'dr-alexandra-konstantinopoulou-whitfield', article_count: 1, }), author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }), ] return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined })) } export function usersFixture() { const user = (id: number, username: string, display: string, email: string, role: string) => ({ kind: 'user', id, username, display_name: display, email, role, status: 'accepted', created_at: '2026-03-12T09:00:00Z', }) return [ user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'), user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'), user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'), ] } function fail(status: number, code?: string): never { throw new ApiError(status, undefined, code) } function parseBody(options: RequestInit | undefined) { return typeof options?.body === 'string' ? (JSON.parse(options.body) as Record) : undefined } function pageOf(backend: Backend, query: URLSearchParams) { const q = (query.get('q') ?? '').toLowerCase() const size = Number(query.get('size') ?? 25) const page = Number(query.get('page') ?? 1) const matching = backend.authors.filter((item) => `${item.name} ${item.slug}`.toLowerCase().includes(q), ) return { items: matching.slice((page - 1) * size, page * size), total: matching.length, page, size, } } export function defaultRoute(backend: Backend, call: Call): unknown { const { method, path, query, body } = call const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path) const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path) if (method === 'GET' && path === '/v1/site/') { return structuredClone(backend.sites) } if (method === 'POST' && path === '/v1/site/') { if (backend.sites.some((site) => site.key === body?.key)) { fail(409, 'site_key_taken') } const created = { id: 100, default_social_image_id: null, locale: null, article_count: 0, ...body, } as unknown as Site backend.sites.push(created) return created } if (siteMatch && method === 'PATCH') { const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1])) if (!site) { fail(404, 'site_not_found') } if (siteMatch[2]) { backend.sites.forEach((candidate) => (candidate.is_default = candidate === site)) } else { Object.assign(site, body) } return structuredClone(site) } if (siteMatch && method === 'DELETE') { const id = Number(siteMatch[1]) const site = backend.sites.find((candidate) => candidate.id === id) if (!site) { fail(404, 'site_not_found') } if (site.is_default) { fail(403, 'default_site') } const reassign = query.get('reassign_to') if (site.article_count > 0 && reassign === null) { fail(409, 'site_has_articles') } backend.sites = backend.sites.filter((candidate) => candidate.id !== id) return undefined } if (method === 'GET' && path === '/v1/author/') { return pageOf(backend, query) } if (method === 'POST' && path === '/v1/author/') { const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body, } backend.authors.push(created as unknown as Author) return created } if (authorMatch && method === 'PATCH') { const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1])) if (!target) { fail(404, 'author_not_found') } Object.assign(target, body) if (body && 'user_id' in body) { target.is_linked = body.user_id !== null } return structuredClone(target) } if (authorMatch && method === 'DELETE') { const id = Number(authorMatch[1]) backend.authors = backend.authors.filter((candidate) => candidate.id !== id) return undefined } if (method === 'GET' && path === '/v1/user/') { return backend.users } if (method === 'GET' && path === '/v1/media/') { return { items: [], total: 0, page: 1, size: 25, counts: { all: 0, unused: 0, duplicates: 0 }, } } throw new Error(`Unexpected request: ${method} ${path}`) } export function createBackend(): Backend { return { sites: sitesFixture(), authors: authorsFixture(), users: usersFixture(), calls: [], responders: {}, } } export function callsTo(backend: Backend, method: string, path: string) { return backend.calls.filter((call) => call.method === method && call.path === path) } export function failWith(status: number, code?: string): Responder { return () => fail(status, code) } export const networkFailure: Responder = () => { throw new NetworkError() } export function pending() { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } export interface MountOptions { role?: string width?: number backend?: Backend author?: MeAuthor email?: string } export const kit: { wrapper: VueWrapper | null; backend: Backend } = { wrapper: null, backend: createBackend(), } export async function mountDirectory( view: 'sites' | 'authors', url: string, options: MountOptions = {}, ) { setViewportWidth(options.width ?? DESKTOP_WIDTH) Element.prototype.scrollIntoView = vi.fn() setActivePinia(createPinia()) const backend = options.backend ?? createBackend() kit.backend = backend const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, email: options.email ?? 'marta@renew.example', displayName: 'Marta Alves', role: (options.role ?? 'owner') as never, ...(options.author ? { author: options.author } : {}), } 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, raw: path, query: new URLSearchParams(search), body: parseBody(init), } backend.calls.push(call) const responder = backend.responders[`${call.method} ${path}`] ?? backend.responders[`${call.method} ${pathname}`] return responder ? responder(call) : defaultRoute(backend, call) }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }), request, } as unknown as ReturnType) await router.push(url) const component = view === 'sites' ? SitesView : AuthorsView kit.wrapper = mount( defineComponent({ render: () => h(UApp, null, { default: () => h(component) }), }), { attachTo: document.body, global: { plugins: [router, uiPlugin] } }, ) await flushPromises() return backend } export async function unmountDirectory() { kit.wrapper?.unmount() kit.wrapper = null vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' } export function textOf(element: Element | null | undefined) { if (!element) { return '' } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const parts: string[] = [] while (walker.nextNode()) { const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim() if (text) { parts.push(text) } } return parts.join(' ') } export function button(label: string | RegExp, root: ParentNode = document.body) { return Array.from(root.querySelectorAll('button, [role="menuitem"], a')).find( (element) => { const text = element.getAttribute('aria-label') || textOf(element) return typeof label === 'string' ? text === label : label.test(text) }, ) } export async function click(label: string | RegExp, root: ParentNode = document.body) { const target = button(label, root) if (!target) { throw new Error(`No control named ${String(label)}`) } target.click() await flushPromises() } export function dialog() { return document.body.querySelector('[role="dialog"], [role="alertdialog"]') } export function rows() { return Array.from( document.body.querySelectorAll( '[data-testid="directory-row"], [data-testid="directory-card"]', ), ) } export function rowTexts() { return rows().map((row) => textOf(row)) } export async function openRowMenu(name: string) { await click(`Actions for ${name}`) } export async function chooseMenuItem(label: string) { await click(label) } export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) { element.value = value element.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() } export async function blur(element: HTMLElement) { element.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) element.dispatchEvent(new Event('blur')) await flushPromises() } export function field(placeholder: string) { return document.body.querySelector(`[placeholder="${placeholder}"]`)! } export function labelled(label: string) { const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) => textOf(element).startsWith(label), ) const id = labelElement?.getAttribute('for') return id ? document.getElementById(id)! : null } export function toasts() { return textOf( document.body.querySelector( '[data-slot="viewport"], [role="region"][aria-label*="otification"]', ), ) }