import { expect, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import { addCollection } from '@iconify/vue' import { getIconData } from '@iconify/utils' import lucide from 'virtual:lucide-subset' import router from '@/router' import { useApi } from '@/composables/useApi' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import ActivityView from '../ActivityView.vue' import { activityItems, pageOf, summaryDefault } from './activityFixtures' addCollection(lucide) export type Responder = (query: URLSearchParams, call: number) => unknown export interface ActivityApiMock { list?: Responder summary?: Responder exportFile?: Responder users?: Responder keys?: Responder } export interface MountOptions extends ActivityApiMock { role?: string desktop?: boolean verified?: boolean path?: string } export interface RecordedCall { path: string query: URLSearchParams } export const LIST_PATH = '/v1/activity' export const SUMMARY_PATH = '/v1/activity/summary' export const EXPORT_PATH = '/v1/activity/export' export const USERS_PATH = '/v1/user/' export const KEYS_PATH = '/v1/api-key/' export const CSV_HEADER = 'when,actor,action,target,outcome,source,ip' export const CSV_WITH_ROWS = `${CSV_HEADER}\n2026-10-06,Marta Alves,Published,Spring release,success,web,203.0.113.24\n2026-10-05,Ines Duarte,Deleted,old-banner.png,success,web,203.0.113.12\n` export const CSV_HEADER_ONLY = `${CSV_HEADER}\n` export function csvBlob(text: string) { return new Blob([text], { type: 'text/csv' }) } export const harness = { calls: [] as RecordedCall[], signals: [] as (AbortSignal | undefined)[], wrapper: null as VueWrapper | null, } const counters = new Map() function respond(responder: Responder | undefined, query: URLSearchParams, key: string) { if (!responder) throw new Error(`Unexpected request: ${key}`) const call = (counters.get(key) ?? 0) + 1 counters.set(key, call) return responder(query, call) } export function callsTo(path: string) { return harness.calls.filter((call) => call.path === path).map((call) => call.query) } export async function mountActivity(options: MountOptions = {}) { const { desktop = true, verified = true, path = '/activity' } = options const role = 'role' in options ? options.role : 'owner' const api: ActivityApiMock = { list: () => pageOf(activityItems), summary: () => summaryDefault, exportFile: () => csvBlob(CSV_WITH_ROWS), users: () => [], keys: () => [], ...options, } harness.calls = [] harness.signals = [] counters.clear() setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH) setActivePinia(createPinia()) const session = useSessionStore() session.session = { id: 2, verified, username: 'ines', displayName: 'Inès Duarte', role: role as 'owner' | undefined, } session.status = 'ready' const record = (path: string) => { const url = new URL(path, 'http://localhost') harness.calls.push({ path: url.pathname, query: url.searchParams }) return url } const request = vi.fn(async (path: string) => { const url = record(path) const key = url.pathname switch (key) { case LIST_PATH: return respond(api.list, url.searchParams, key) case SUMMARY_PATH: return respond(api.summary, url.searchParams, key) case USERS_PATH: return respond(api.users, url.searchParams, key) case KEYS_PATH: return respond(api.keys, url.searchParams, key) default: throw new Error(`Unexpected request: ${path}`) } }) const requestBlob = vi.fn(async (path: string, options?: RequestInit) => { harness.signals.push(options?.signal ?? undefined) const url = record(path) return respond(api.exportFile, url.searchParams, url.pathname) as Blob }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified, role }), request, requestBlob, } as unknown as ReturnType) await router.push(path) harness.wrapper = mount(ActivityView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return harness.wrapper } export function unmountActivity() { harness.wrapper?.unmount() harness.wrapper = null document.body.innerHTML = '' } export function textOf(element: Element | null | undefined) { return (element?.textContent ?? '').replace(/\s+/g, ' ').trim() } export function rows() { return Array.from( document.body.querySelectorAll( '[data-testid="activity-row"], [data-testid="activity-card"]', ), ) } export function days() { return Array.from(document.body.querySelectorAll('[data-testid="activity-day"]')).map(textOf) } export function findButton(text: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => textOf(button) === text || button.getAttribute('aria-label') === text, ) } export async function click(element: Element | null | undefined) { ;(element as HTMLElement | undefined)?.click() await flushPromises() } export async function clickButton(text: string) { await click(findButton(text)) } export function chipTexts() { return Array.from(document.body.querySelectorAll('[data-testid="activity-chip"]')).map(textOf) } export function stripCards() { return Array.from( document.body.querySelectorAll('[data-testid="activity-strip-card"]'), ) } export async function setSearch(value: string) { const input = document.body.querySelector('input[type="text"]')! input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() return input } export function lastListQuery() { const queries = callsTo(LIST_PATH) return queries[queries.length - 1] } export function queryObject(query: URLSearchParams | undefined) { return Object.fromEntries(query?.entries() ?? []) } export { DESKTOP_WIDTH, MOBILE_WIDTH } export function hasIcon(root: Element | null | undefined, name: string) { const body = getIconData(lucide, name)?.body?.replace(/<(\w+)([^>]*?)\/>/g, '<$1$2>') if (!root || !body) return false return Array.from(root.querySelectorAll('svg')).some((svg) => svg.innerHTML === body) } export async function expectIcon(root: Element | null | undefined, name: string, present = true) { if (present) { await vi.waitFor(() => expect(hasIcon(root, name)).toBe(true)) return } await new Promise((resolve) => setTimeout(resolve, 50)) expect(hasIcon(root, name)).toBe(false) } export function exportButton() { return findButton('Export') ?? findButton('Exporter') } export function menuItems() { return Array.from(document.body.querySelectorAll('[role="menuitem"]')) } export function menuItem(label: string) { return menuItems().find((item) => textOf(item).startsWith(label)) } export async function openExportMenu() { await click(exportButton()) await vi.waitFor(() => expect(menuItems().length).toBe(2)) } export function dialogElement() { return document.body.querySelector('[role="dialog"]') } export function dialogButton(label: string) { return Array.from(dialogElement()?.querySelectorAll('button') ?? []).find( (button) => textOf(button) === label || button.getAttribute('aria-label') === label, ) } export function dialogButtons() { return Array.from( dialogElement()?.querySelectorAll( 'footer button, [data-slot="footer"] button', ) ?? [], ).map(textOf) }