feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user