feat: activity screen in the SPA, route /activity

This commit is contained in:
fsylvain committed 2026-10-07 01:48:21 +02:00
1 parent e37a46a027
commit 35db442cd3
47 files changed
+7582 -11

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
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
}
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 harness = {
calls: [] as RecordedCall[],
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 } = options
const role = 'role' in options ? options.role : 'owner'
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
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) => {
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('/activity')
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)
}