254 lines
7.5 KiB
TypeScript
254 lines
7.5 KiB
TypeScript
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<string, number>()
|
|
|
|
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<typeof useApi>)
|
|
|
|
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<HTMLElement>(
|
|
'[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<HTMLButtonElement>('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<HTMLElement>('[data-testid="activity-strip-card"]'),
|
|
)
|
|
}
|
|
|
|
export async function setSearch(value: string) {
|
|
const input = document.body.querySelector<HTMLInputElement>('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></$1>')
|
|
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<HTMLElement>('[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<HTMLElement>('[role="dialog"]')
|
|
}
|
|
|
|
export function dialogButton(label: string) {
|
|
return Array.from(dialogElement()?.querySelectorAll<HTMLButtonElement>('button') ?? []).find(
|
|
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
|
|
)
|
|
}
|
|
|
|
export function dialogButtons() {
|
|
return Array.from(
|
|
dialogElement()?.querySelectorAll<HTMLButtonElement>(
|
|
'footer button, [data-slot="footer"] button',
|
|
) ?? [],
|
|
).map(textOf)
|
|
}
|