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,488 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import ActivityPager from '@/components/ActivityPager.vue'
|
||||
import type { ActivityChange, ActivityItem } from '@/lib/activity'
|
||||
import {
|
||||
LIST_PATH,
|
||||
callsTo,
|
||||
click,
|
||||
expectIcon,
|
||||
findButton,
|
||||
harness,
|
||||
mountActivity,
|
||||
queryObject,
|
||||
rows,
|
||||
textOf,
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import {
|
||||
activityItems,
|
||||
anonymous,
|
||||
makeItem,
|
||||
pageOf,
|
||||
system,
|
||||
target,
|
||||
LONG_VALUE,
|
||||
} from './activityFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
unmountActivity()
|
||||
vi.mocked(useApi).mockReset()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function panel() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||
}
|
||||
|
||||
function details() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="activity-details"]')
|
||||
}
|
||||
|
||||
async function openRow(index: number) {
|
||||
const row = rows()[index]!
|
||||
row.focus()
|
||||
row.click()
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(details()).toBeTruthy())
|
||||
return row
|
||||
}
|
||||
|
||||
async function openWith(item: ActivityItem, desktop = true) {
|
||||
await mountActivity({ desktop, list: () => pageOf([item]) })
|
||||
await openRow(0)
|
||||
}
|
||||
|
||||
function pressEscape() {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
}
|
||||
|
||||
describe('Details: a row opens the slide-over and focus returns on close', () => {
|
||||
it('opens from a click, marks the row selected and closes with Escape', async () => {
|
||||
await mountActivity()
|
||||
const row = await openRow(1)
|
||||
|
||||
expect(textOf(panel()?.querySelector('[data-slot="title"], h2'))).toContain(
|
||||
'Activity details',
|
||||
)
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(row.className).toContain('bg-elevated')
|
||||
expect(textOf(panel())).toContain('Event 2 of 1,284')
|
||||
|
||||
pressEscape()
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(details()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
expect(row.getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('opens from Enter on a focused row and closes with the close button', async () => {
|
||||
await mountActivity()
|
||||
const row = rows()[0]!
|
||||
row.focus()
|
||||
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(details()).toBeTruthy())
|
||||
|
||||
await click(panel()!.querySelector('button[aria-label="Close"]'))
|
||||
await vi.waitFor(() => expect(details()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('closes when the scrim is pressed', async () => {
|
||||
await mountActivity()
|
||||
const row = await openRow(0)
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(details()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Details: content', () => {
|
||||
it('shows the edit with a diff and never the body text', async () => {
|
||||
await mountActivity()
|
||||
await openRow(1)
|
||||
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
|
||||
'Edited the article Why we reuse original floorboards',
|
||||
)
|
||||
const changes = Array.from(details()!.querySelectorAll('[data-testid="activity-change"]'))
|
||||
expect(changes).toHaveLength(2)
|
||||
expect(textOf(changes[0])).toContain('Title')
|
||||
expect(textOf(changes[0]!.querySelector('[data-testid="activity-before"]'))).toBe(
|
||||
'Why we reuse floorboards',
|
||||
)
|
||||
expect(textOf(changes[0]!.querySelector('[data-testid="activity-after"]'))).toBe(
|
||||
'Why we reuse original floorboards',
|
||||
)
|
||||
expect(textOf(changes[1])).toBe('Body Body edited (+312 characters)')
|
||||
expect(changes[1]!.querySelector('[data-testid="activity-before"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 'Body edited (+1 character)'],
|
||||
[-48, 'Body edited (-48 characters)'],
|
||||
])('words a body delta of %i', async (delta, text) => {
|
||||
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
|
||||
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
|
||||
})
|
||||
|
||||
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
|
||||
const changes: ActivityChange[] = [
|
||||
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
|
||||
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
|
||||
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
|
||||
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
|
||||
]
|
||||
await openWith(makeItem({ id: 1, changes }))
|
||||
|
||||
const rowsText = Array.from(
|
||||
details()!.querySelectorAll('[data-testid="activity-change"]'),
|
||||
).map(textOf)
|
||||
expect(rowsText).toEqual([
|
||||
'Publish stateBeforeDraftAfterPublished',
|
||||
'File nameBeforea.pngAfterb.png',
|
||||
'Display nameBeforeSamAfterSamuel',
|
||||
'taglineBeforexAftery',
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps long values and toggles Show more on that row only', async () => {
|
||||
const scrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight')
|
||||
const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return (this.textContent ?? '').length > 100 ? 80 : 20
|
||||
},
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 40
|
||||
},
|
||||
})
|
||||
try {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
changes: [
|
||||
{
|
||||
field: 'title',
|
||||
kind: 'text',
|
||||
before: LONG_VALUE,
|
||||
after: `${LONG_VALUE}more`,
|
||||
},
|
||||
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
|
||||
|
||||
const changes = Array.from(
|
||||
details()!.querySelectorAll('[data-testid="activity-change"]'),
|
||||
)
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
|
||||
expect(changes[1]!.querySelector('button')).toBeNull()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(button) => textOf(button) === 'Show more',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
|
||||
await click(findButton('Show more'))
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
|
||||
expect(findButton('Show less')).toBeTruthy()
|
||||
await click(findButton('Show less'))
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
|
||||
} finally {
|
||||
if (scrollHeight)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
|
||||
if (clientHeight)
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the failure reason and wraps a very long target title', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
outcome: 'failed',
|
||||
failure_reason: 'The article has no cover image. Add one, then publish again.',
|
||||
target: target('article', '6', 'T'.repeat(150)),
|
||||
}),
|
||||
)
|
||||
|
||||
const sentence = details()!.querySelector('[data-testid="activity-details-sentence"]')!
|
||||
expect(sentence.className).toContain('wrap-anywhere')
|
||||
expect(textOf(sentence.querySelector('.font-semibold'))).toBe('T'.repeat(150))
|
||||
expect(textOf(details())).toContain('Failed')
|
||||
const alert = details()!.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain('Why it failed')
|
||||
expect(textOf(alert)).toContain(
|
||||
'The article has no cover image. Add one, then publish again.',
|
||||
)
|
||||
expect(
|
||||
details()!.querySelector('[data-testid="activity-target-section"] .wrap-anywhere'),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no Why it failed alert for a failed event without a reason', async () => {
|
||||
await openWith(makeItem({ id: 1, outcome: 'failed', failure_reason: null }))
|
||||
|
||||
expect(details()!.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a deleted target struck through with its explanation and no Changes', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'media.delete',
|
||||
category: 'media',
|
||||
target: target('image', '9', 'old-banner.png', false),
|
||||
}),
|
||||
)
|
||||
|
||||
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
|
||||
expect(section.querySelector('.line-through')?.textContent).toBe('old-banner.png')
|
||||
expect(textOf(section)).toContain('Deleted')
|
||||
expect(textOf(section)).toContain('Image · 9')
|
||||
expect(textOf(section)).toContain('This item was deleted. Its name is kept for reference.')
|
||||
expect(textOf(details())).not.toContain('Changes')
|
||||
})
|
||||
|
||||
it('shows a living target as a link with its type and id', async () => {
|
||||
await openWith(makeItem({ id: 1 }))
|
||||
|
||||
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
|
||||
expect(section.querySelector('a')?.getAttribute('href')).toBe('/article/8/edit')
|
||||
expect(textOf(section)).toContain('Article · 8')
|
||||
await expectIcon(section, 'arrow-up-right')
|
||||
})
|
||||
|
||||
it('omits the Target section when there is no target', async () => {
|
||||
await openWith(makeItem({ id: 1, action: 'auth.login', target: null }))
|
||||
|
||||
expect(details()!.querySelector('[data-testid="activity-target-section"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a system event without a human actor', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'system.media_purge',
|
||||
category: 'system',
|
||||
actor: system,
|
||||
target: null,
|
||||
source: 'system',
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
params: { merged: 3, articles_updated: 5 },
|
||||
}),
|
||||
)
|
||||
|
||||
const text = textOf(details())
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
|
||||
'SystemSystem',
|
||||
)
|
||||
await expectIcon(details()!.querySelector('[data-testid="activity-actor"]'), 'cog')
|
||||
expect(text).toContain('System task')
|
||||
expect(text.match(/Not applicable/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a failed login as not signed in with the reason, IP and browser', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'auth.login_failed',
|
||||
category: 'auth',
|
||||
actor: anonymous('alex@example.com'),
|
||||
target: null,
|
||||
outcome: 'failed',
|
||||
security: true,
|
||||
failure_reason: 'Wrong username or password.',
|
||||
ip: '198.51.100.77',
|
||||
user_agent: 'Chrome 129 on Windows',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
|
||||
'Failed login attempt for alex@example.com',
|
||||
)
|
||||
const text = textOf(details())
|
||||
expect(text).toContain('Security')
|
||||
expect(text).toContain('Failed')
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
|
||||
'Not signed in',
|
||||
)
|
||||
expect(textOf(details()!.querySelector('[role="alert"]'))).toContain(
|
||||
'Wrong username or password.',
|
||||
)
|
||||
expect(text).toContain('198.51.100.77')
|
||||
expect(text).toContain('Chrome 129 on Windows')
|
||||
})
|
||||
|
||||
it('shows the time in your time zone and in UTC', async () => {
|
||||
await openWith(makeItem({ id: 1, created_at: '2026-10-06T10:42:18Z' }))
|
||||
|
||||
const when = Array.from(details()!.querySelectorAll('dt')).find(
|
||||
(term) => textOf(term) === 'When',
|
||||
)!.nextElementSibling!
|
||||
const lines = Array.from(when.querySelectorAll('span')).map(textOf)
|
||||
expect(lines[0]).toMatch(/^6 Oct 2026, \d{2}:\d{2}:\d{2} \(your time\)$/)
|
||||
expect(lines[1]).toBe('6 Oct 2026, 10:42:18 UTC')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['web', 'Web app'],
|
||||
['api_key', 'API key'],
|
||||
['system', 'System task'],
|
||||
] as const)('reads source %s as %s', async (source, label) => {
|
||||
await openWith(makeItem({ id: 1, source }))
|
||||
|
||||
const term = Array.from(details()!.querySelectorAll('dt')).find(
|
||||
(candidate) => textOf(candidate) === 'Source',
|
||||
)!
|
||||
expect(textOf(term.nextElementSibling)).toBe(label)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Details: Previous and Next across pages', () => {
|
||||
function numbered(page: number) {
|
||||
return Array.from({ length: 25 }, (_, index) =>
|
||||
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
|
||||
)
|
||||
}
|
||||
|
||||
function pagedList(size = 25) {
|
||||
return (query: URLSearchParams) => {
|
||||
const page = Number(query.get('page'))
|
||||
return pageOf(numbered(page), { page, size })
|
||||
}
|
||||
}
|
||||
|
||||
const footer = () => textOf(panel())
|
||||
const previous = () => findButton('Previous event')!
|
||||
const next = () => findButton('Next event')!
|
||||
|
||||
it('moves through the list, fetches the neighbour pages and stops at the ends', async () => {
|
||||
await mountActivity({ list: pagedList() })
|
||||
await openRow(0)
|
||||
|
||||
expect(previous().disabled).toBe(true)
|
||||
expect(footer()).toContain('Event 1 of 1,284')
|
||||
for (let step = 0; step < 24; step++) await click(next())
|
||||
expect(footer()).toContain('Event 25 of 1,284')
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(1)
|
||||
|
||||
await click(next())
|
||||
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
|
||||
range: '7d',
|
||||
page: '2',
|
||||
size: '25',
|
||||
})
|
||||
expect(footer()).toContain('Event 26 of 1,284')
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 26–50 of 1,284',
|
||||
)
|
||||
expect(
|
||||
textOf(details()!.querySelector('[data-testid="activity-details-sentence"]')),
|
||||
).toContain('Spring release')
|
||||
|
||||
await click(previous())
|
||||
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
|
||||
expect(footer()).toContain('Event 25 of 1,284')
|
||||
expect(
|
||||
rows()
|
||||
.map((row) => row.getAttribute('data-activity-id'))
|
||||
.at(-1),
|
||||
).toBe('25')
|
||||
})
|
||||
|
||||
it('disables Next on the very last event', async () => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
|
||||
})
|
||||
await openRow(1)
|
||||
|
||||
expect(next().disabled).toBe(true)
|
||||
expect(footer()).toContain('Event 2 of 2')
|
||||
})
|
||||
|
||||
it('keeps the panel on the current event when the neighbour page fails', async () => {
|
||||
await mountActivity({
|
||||
list: (query) => {
|
||||
if (query.get('page') === '2') throw new ApiError(500)
|
||||
return pageOf(numbered(1))
|
||||
},
|
||||
})
|
||||
await openRow(24)
|
||||
const ids = rows().map((row) => row.getAttribute('data-activity-id'))
|
||||
|
||||
await click(next())
|
||||
|
||||
expect(footer()).toContain('Event 25 of 1,284')
|
||||
expect(next().disabled).toBe(false)
|
||||
expect(rows().map((row) => row.getAttribute('data-activity-id'))).toEqual(ids)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('numbers events from the page size', async () => {
|
||||
await mountActivity({ list: pagedList(50) })
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
|
||||
await flushPromises()
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
await openRow(0)
|
||||
|
||||
expect(footer()).toContain('Event 51 of 1,284')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Details below md: a bottom sheet', () => {
|
||||
it('shows the position in the header and full-width buttons without footer text', async () => {
|
||||
await mountActivity({ desktop: false })
|
||||
await openRow(1)
|
||||
|
||||
const dialog = panel()!
|
||||
expect(textOf(dialog)).toContain('Activity details')
|
||||
expect(textOf(dialog)).toContain('2 of 1,284')
|
||||
expect(textOf(dialog)).not.toContain('Event 2 of 1,284')
|
||||
expect(findButton('Previous event')!.className).toContain('max-md:flex-1')
|
||||
expect(findButton('Next event')!.className).toContain('max-md:flex-1')
|
||||
expect(dialog.getAttribute('data-side') ?? dialog.className).toContain('bottom')
|
||||
expect(dialog.className).toContain('h-[88%]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Details: counts', () => {
|
||||
it('counts positions against the filtered total', async () => {
|
||||
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
|
||||
await openRow(0)
|
||||
|
||||
expect(textOf(panel())).toContain('Event 1 of 41')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,509 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { CalendarDate } from '@internationalized/date'
|
||||
import UCalendar from '@nuxt/ui/components/Calendar.vue'
|
||||
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
|
||||
import ActivityPager from '@/components/ActivityPager.vue'
|
||||
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
|
||||
import {
|
||||
KEYS_PATH,
|
||||
LIST_PATH,
|
||||
SUMMARY_PATH,
|
||||
USERS_PATH,
|
||||
callsTo,
|
||||
chipTexts,
|
||||
click,
|
||||
clickButton,
|
||||
findButton,
|
||||
harness,
|
||||
lastListQuery,
|
||||
mountActivity,
|
||||
queryObject,
|
||||
rows,
|
||||
stripCards,
|
||||
textOf,
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||
})
|
||||
|
||||
const DEFAULT_QUERY = { range: '7d', page: '1', size: '25' }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
unmountActivity()
|
||||
vi.mocked(useApi).mockReset()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function searchInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||
}
|
||||
|
||||
async function typeSearch(value: string) {
|
||||
const input = searchInput()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
return input
|
||||
}
|
||||
|
||||
async function goToPage(page: number) {
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', page)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function group(label: string) {
|
||||
return document.body.querySelector(`[role="group"][aria-label="${label}"]`)!
|
||||
}
|
||||
|
||||
async function clickIn(label: string, text: string) {
|
||||
const button = Array.from(group(label).querySelectorAll('button')).find(
|
||||
(candidate) => textOf(candidate) === text,
|
||||
)
|
||||
await click(button)
|
||||
}
|
||||
|
||||
describe('Filter bar: each control sends its query parameter and resets the page', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
})
|
||||
|
||||
async function onPage3() {
|
||||
await mountActivity({
|
||||
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
|
||||
})
|
||||
await goToPage(3)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
|
||||
}
|
||||
|
||||
it('types "spring" and waits 300 ms', async () => {
|
||||
await onPage3()
|
||||
const before = callsTo(LIST_PATH).length
|
||||
await typeSearch('spring')
|
||||
|
||||
vi.advanceTimersByTime(299)
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
|
||||
})
|
||||
|
||||
it('types "spring" and presses Enter at once', async () => {
|
||||
await onPage3()
|
||||
const before = callsTo(LIST_PATH).length
|
||||
const input = await typeSearch('spring')
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Marta Alves (user 3)', 'user:3', 'Marta Alves', { actor: 'user:3' }],
|
||||
['Website sync (API key 7)', 'key:7', 'Website sync', { actor: 'key:7' }],
|
||||
['System', 'system', 'System', { actor: 'system' }],
|
||||
])('chooses Person %s', async (_, value, label, expected) => {
|
||||
await onPage3()
|
||||
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
|
||||
await flushPromises()
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
|
||||
expect(chipTexts()).toEqual([`Person: ${label}`])
|
||||
})
|
||||
|
||||
it('chooses Category "Users and invites"', async () => {
|
||||
await onPage3()
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
|
||||
})
|
||||
|
||||
it('chooses Outcome "Succeeded", then "All"', async () => {
|
||||
await onPage3()
|
||||
await clickIn('Outcome', 'Succeeded')
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
|
||||
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
|
||||
|
||||
await clickIn('Outcome', 'All')
|
||||
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Today', { range: 'today', page: '1', size: '25' }],
|
||||
['30 days', { range: '30d', page: '1', size: '25' }],
|
||||
])('chooses "%s"', async (label, expected) => {
|
||||
await onPage3()
|
||||
await clickIn('Dates', label)
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual(expected)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filter bar: custom dates', () => {
|
||||
it('applies on the second date and shows the range chip', async () => {
|
||||
await mountActivity()
|
||||
await clickIn('Dates', 'Custom')
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
|
||||
)
|
||||
const calendar = harness.wrapper!.findComponent(UCalendar)
|
||||
|
||||
calendar.vm.$emit('update:modelValue', {
|
||||
start: new CalendarDate(2026, 9, 1),
|
||||
end: undefined,
|
||||
})
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(1)
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
|
||||
calendar.vm.$emit('update:modelValue', {
|
||||
start: new CalendarDate(2026, 9, 1),
|
||||
end: new CalendarDate(2026, 9, 30),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
|
||||
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
|
||||
})
|
||||
|
||||
it('words a range across months', async () => {
|
||||
await mountActivity()
|
||||
await clickIn('Dates', 'Custom')
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
|
||||
)
|
||||
harness.wrapper!.findComponent(UCalendar).vm.$emit('update:modelValue', {
|
||||
start: new CalendarDate(2026, 9, 28),
|
||||
end: new CalendarDate(2026, 10, 3),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(chipTexts()).toEqual(['Dates: 28 Sep – 3 Oct 2026'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chips', () => {
|
||||
it('removes one filter at a time and clears all', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountActivity()
|
||||
await typeSearch('spring')
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
await clickIn('Outcome', 'Failed')
|
||||
await clickIn('Dates', '30 days')
|
||||
expect(chipTexts()).toEqual(['Search: spring', 'Outcome: Failed', 'Dates: 30 days'])
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
|
||||
|
||||
await click(findButton('Remove filter Outcome: Failed'))
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
range: '30d',
|
||||
page: '1',
|
||||
size: '25',
|
||||
q: 'spring',
|
||||
})
|
||||
expect(chipTexts()).toEqual(['Search: spring', 'Dates: 30 days'])
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
|
||||
|
||||
await clickButton('Clear all')
|
||||
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
|
||||
expect(chipTexts()).toEqual([])
|
||||
expect(findButton('Clear all')).toBeUndefined()
|
||||
expect(searchInput().value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Strip cards apply their filter', () => {
|
||||
it('clears every filter from the Total card', async () => {
|
||||
await mountActivity()
|
||||
await clickIn('Outcome', 'Failed')
|
||||
expect(chipTexts()).toEqual(['Outcome: Failed'])
|
||||
|
||||
await click(stripCards()[0])
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
|
||||
expect(chipTexts()).toEqual([])
|
||||
stripCards().forEach((card) => expect(card.getAttribute('aria-pressed')).not.toBe('true'))
|
||||
})
|
||||
|
||||
it('does nothing from the Active people card', async () => {
|
||||
await mountActivity()
|
||||
const before = harness.calls.length
|
||||
|
||||
await click(stripCards()[1])
|
||||
|
||||
expect(harness.calls).toHaveLength(before)
|
||||
expect(stripCards()[1]!.hasAttribute('aria-pressed')).toBe(false)
|
||||
})
|
||||
|
||||
it('applies outcome=failed from the Failed card and marks it pressed', async () => {
|
||||
await mountActivity()
|
||||
await click(stripCards()[2])
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
|
||||
expect(chipTexts()).toEqual(['Outcome: Failed'])
|
||||
expect(findButton('Clear all')?.disabled).toBe(false)
|
||||
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(stripCards()[2]!.className).toContain('border-primary')
|
||||
})
|
||||
|
||||
it('applies security=true from the Security card and marks it pressed', async () => {
|
||||
await mountActivity()
|
||||
await click(stripCards()[3])
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
|
||||
expect(chipTexts()).toEqual(['Security events'])
|
||||
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('stacks the Failed card on other filters without asking for the summary again', async () => {
|
||||
await mountActivity({
|
||||
list: (query) =>
|
||||
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
|
||||
})
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
|
||||
await flushPromises()
|
||||
await clickIn('Dates', '30 days')
|
||||
await click(stripCards()[2])
|
||||
|
||||
expect(chipTexts()).toEqual([
|
||||
'Category: Authentication',
|
||||
'Outcome: Failed',
|
||||
'Dates: 30 days',
|
||||
])
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–6 of 6',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Person menu', () => {
|
||||
it('loads its options once, on first open, and lists users, keys then System', async () => {
|
||||
await mountActivity({
|
||||
users: () => [
|
||||
{
|
||||
kind: 'user',
|
||||
id: 3,
|
||||
username: 'marta',
|
||||
display_name: 'Marta Alves',
|
||||
role: 'owner',
|
||||
},
|
||||
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
|
||||
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
|
||||
],
|
||||
keys: () => [{ id: 7, name: 'Website sync' }],
|
||||
})
|
||||
expect(callsTo(USERS_PATH)).toHaveLength(0)
|
||||
expect(callsTo(KEYS_PATH)).toHaveLength(0)
|
||||
|
||||
const person = harness.wrapper!.findComponent(ActivityPersonSelect)
|
||||
person.findComponent(USelectMenu).vm.$emit('update:open', true)
|
||||
await flushPromises()
|
||||
expect(callsTo(USERS_PATH)).toHaveLength(1)
|
||||
expect(callsTo(KEYS_PATH)).toHaveLength(1)
|
||||
|
||||
const groups = person.findComponent(USelectMenu).props('items') as Record<
|
||||
string,
|
||||
string
|
||||
>[][]
|
||||
expect(groups.map((items) => items.map((item) => item.label))).toEqual([
|
||||
['Users', 'Marta Alves', 'jonas'],
|
||||
['API keys', 'Website sync'],
|
||||
['System'],
|
||||
])
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((item) => item.value)
|
||||
.filter(Boolean),
|
||||
).toEqual(['user:3', 'user:4', 'key:7', 'system'])
|
||||
|
||||
person.findComponent(USelectMenu).vm.$emit('update:open', true)
|
||||
await flushPromises()
|
||||
expect(callsTo(USERS_PATH)).toHaveLength(1)
|
||||
expect(callsTo(KEYS_PATH)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('moves to page 2, changes the page size and reaches the last page', async () => {
|
||||
await mountActivity({
|
||||
list: (query) =>
|
||||
pageOf(activityItems, {
|
||||
page: Number(query.get('page')),
|
||||
size: Number(query.get('size')),
|
||||
}),
|
||||
})
|
||||
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
|
||||
|
||||
await goToPage(2)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
|
||||
expect(pager()).toContain('Showing 26–50 of 1,284')
|
||||
const activePage = document.body.querySelector(
|
||||
'[data-testid="activity-pager"] [aria-current="page"]',
|
||||
)
|
||||
expect(textOf(activePage)).toBe('2')
|
||||
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
|
||||
await flushPromises()
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
|
||||
expect(pager()).toContain('Showing 1–50 of 1,284')
|
||||
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
|
||||
await flushPromises()
|
||||
await goToPage(52)
|
||||
expect(pager()).toContain('Showing 1,276–1,284 of 1,284')
|
||||
expect(harness.wrapper!.findComponent(ActivityPager).props('page')).toBe(52)
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile: the list is cards and the controls collapse into a sheet', () => {
|
||||
function mobileList() {
|
||||
return mountActivity({
|
||||
desktop: false,
|
||||
list: () => pageOf(activityItems, { total: 6 }),
|
||||
})
|
||||
}
|
||||
|
||||
async function applyAuthFailed() {
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('shows search and Filters with a badge, cards, and the short pager', async () => {
|
||||
await mobileList()
|
||||
const sheet = () => document.body.querySelector('[data-testid="activity-filters-sheet"]')
|
||||
|
||||
expect(document.body.querySelector('[role="group"][aria-label="Outcome"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-filter-count"]')).toBeNull()
|
||||
expect(textOf(findButton('Filters'))).toBe('Filters')
|
||||
expect(sheet()).toBeNull()
|
||||
|
||||
await click(findButton('Filters'))
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
|
||||
await flushPromises()
|
||||
await clickIn('Outcome', 'Failed')
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
|
||||
'2',
|
||||
)
|
||||
|
||||
expect(rows().every((row) => row.getAttribute('data-testid') === 'activity-card')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Page 1 of 1',
|
||||
)
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'1–6 of 6',
|
||||
)
|
||||
})
|
||||
|
||||
it('draws the sheet with its footer and closes it from "Show 6 results"', async () => {
|
||||
await mobileList()
|
||||
await click(findButton('Filters'))
|
||||
await applyAuthFailed()
|
||||
|
||||
const sheet = document.body.querySelector('[data-testid="activity-filters-sheet"]')!
|
||||
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Filters')
|
||||
expect(textOf(sheet)).toContain('Person')
|
||||
expect(textOf(sheet)).toContain('Category')
|
||||
expect(textOf(sheet)).toContain('Outcome')
|
||||
expect(textOf(sheet)).toContain('Dates')
|
||||
expect(findButton('Clear all')?.disabled).toBe(false)
|
||||
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(0)
|
||||
|
||||
await clickIn('Dates', 'Custom')
|
||||
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(2)
|
||||
|
||||
await clickButton('Show 6 results')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="activity-filters-sheet"]'),
|
||||
).toBeNull(),
|
||||
)
|
||||
expect(chipTexts()).toEqual(['Category: Authentication'])
|
||||
})
|
||||
|
||||
it('applies the two custom dates from the sheet', async () => {
|
||||
await mobileList()
|
||||
await click(findButton('Filters'))
|
||||
await clickIn('Dates', 'Custom')
|
||||
|
||||
const [from, to] = Array.from(
|
||||
document.body.querySelectorAll<HTMLInputElement>('input[type="date"]'),
|
||||
)
|
||||
from!.value = '2026-09-01'
|
||||
from!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(1)
|
||||
|
||||
to!.value = '2026-09-30'
|
||||
to!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
range: 'custom',
|
||||
from: '2026-09-01',
|
||||
to: '2026-09-30',
|
||||
page: '1',
|
||||
size: '25',
|
||||
})
|
||||
})
|
||||
|
||||
it('words the footer for a single result and disables Clear all at 0 filters', async () => {
|
||||
await mountActivity({
|
||||
desktop: false,
|
||||
list: () => pageOf([activityItems[0]!], { total: 1 }),
|
||||
})
|
||||
await click(findButton('Filters'))
|
||||
|
||||
expect(textOf(findButton('Show 1 result'))).toBe('Show 1 result')
|
||||
expect(findButton('Clear all')?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Summary follows only the date range', () => {
|
||||
it('does not request the summary again for the other filters', async () => {
|
||||
await mountActivity({ summary: () => summaryDefault })
|
||||
await clickIn('Outcome', 'Failed')
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import {
|
||||
EXPORT_PATH,
|
||||
LIST_PATH,
|
||||
SUMMARY_PATH,
|
||||
callsTo,
|
||||
click,
|
||||
days,
|
||||
findButton,
|
||||
harness,
|
||||
mountActivity,
|
||||
queryObject,
|
||||
rows,
|
||||
stripCards,
|
||||
textOf,
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
|
||||
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const clicked: HTMLAnchorElement[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
clicked.length = 0
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
URL.createObjectURL = vi.fn(() => 'blob:activity')
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
clicked.push(this)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
unmountActivity()
|
||||
vi.mocked(useApi).mockReset()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function toastCall(index = 0) {
|
||||
const options = toastAdd.mock.calls[index]![0] as {
|
||||
color: string
|
||||
icon: string
|
||||
title: string
|
||||
}
|
||||
return { color: options.color, icon: options.icon, title: options.title }
|
||||
}
|
||||
|
||||
describe('Export', () => {
|
||||
it('prepares the file with the list filters, then downloads it and toasts', async () => {
|
||||
let release: (blob: Blob) => void = () => undefined
|
||||
const pending = new Promise<Blob>((resolve) => (release = resolve))
|
||||
await mountActivity({ exportFile: () => pending })
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
|
||||
await flushPromises()
|
||||
await click(
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="group"][aria-label="Dates"] button',
|
||||
),
|
||||
).find((button) => textOf(button) === '30 days'),
|
||||
)
|
||||
|
||||
await click(findButton('Export CSV'))
|
||||
|
||||
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
|
||||
const busy = findButton('Preparing file…')!
|
||||
expect(busy.disabled).toBe(true)
|
||||
expect(clicked).toHaveLength(0)
|
||||
|
||||
release(new Blob(['when,actor'], { type: 'text/csv' }))
|
||||
await flushPromises()
|
||||
|
||||
expect(clicked).toHaveLength(1)
|
||||
expect(clicked[0]!.download).toBe('activity.csv')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(toastCall()).toEqual({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Export started. Your file will download shortly.',
|
||||
})
|
||||
expect(findButton('Export CSV')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500 response', () => new ApiError(500)],
|
||||
['a 403 response', () => new ApiError(403)],
|
||||
['a network failure', () => new NetworkError()],
|
||||
])('raises an error toast after %s and re-enables the button', async (_, failure) => {
|
||||
await mountActivity({
|
||||
exportFile: () => {
|
||||
throw failure()
|
||||
},
|
||||
})
|
||||
|
||||
await click(findButton('Export CSV'))
|
||||
|
||||
expect(toastCall()).toEqual({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't export activity. Try again.",
|
||||
})
|
||||
expect(clicked).toHaveLength(0)
|
||||
expect(findButton('Export CSV')?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('My activity: a role that is not owner or admin sees its own events', () => {
|
||||
it.each([['editor'], [undefined]])('role %s', async (role) => {
|
||||
await mountActivity({
|
||||
role,
|
||||
list: () => pageOf(activityItems, { total: 41 }),
|
||||
})
|
||||
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('My activity')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
|
||||
'My activity',
|
||||
)
|
||||
expect(textOf(document.body)).toContain("Everything you've done on this site.")
|
||||
expect(stripCards()).toHaveLength(0)
|
||||
expect(textOf(document.body)).not.toContain('All people')
|
||||
expect(findButton('Export CSV')).toBeUndefined()
|
||||
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
|
||||
textOf,
|
||||
)
|
||||
expect(headers).toEqual(['When', 'Action', 'Target', 'Outcome'])
|
||||
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
|
||||
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–25 of 41',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows no actor line on the cards below md', async () => {
|
||||
await mountActivity({ role: 'editor', desktop: false })
|
||||
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows its own empty copy', async () => {
|
||||
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
|
||||
|
||||
expect(textOf(document.body)).toContain('No activity yet')
|
||||
expect(textOf(document.body)).toContain(
|
||||
'Your actions will appear here once you start working on the site.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the same details panel with the user position', async () => {
|
||||
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
|
||||
rows()[0]!.click()
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
|
||||
|
||||
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Event 1 of 41')
|
||||
expect((findButton('Previous event') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-actor"]'))).toBe(
|
||||
'MAMarta Alves',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The French interface', () => {
|
||||
it('composes the chrome, the strip, the sentences and the day separators in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountActivity({
|
||||
summary: () => summaryDefault,
|
||||
list: () =>
|
||||
pageOf([
|
||||
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
|
||||
makeItem({
|
||||
id: 2,
|
||||
created_at: '2026-10-03T12:20:00Z',
|
||||
outcome: 'failed',
|
||||
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
|
||||
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
|
||||
const strip = stripCards().map(textOf)
|
||||
expect(strip[0]).toContain('Actions au total')
|
||||
expect(strip[1]).toContain('Personnes actives')
|
||||
expect(strip[1]).toContain('7 personnes')
|
||||
expect(strip[2]).toContain('Actions en échec')
|
||||
expect(strip[3]).toContain('Événements de sécurité')
|
||||
expect(strip[3]).toContain('11 signalés')
|
||||
expect(days()).toEqual(['3 oct. 2026'])
|
||||
const sentences = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="activity-sentence"]'),
|
||||
).map(textOf)
|
||||
expect(sentences).toEqual([
|
||||
'Article publié : Spring release',
|
||||
"Échec de la publication de l'article Spring release",
|
||||
])
|
||||
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||
expect(search.placeholder).toBe("Rechercher dans l'activité")
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Lignes par page',
|
||||
)
|
||||
expect(textOf(rows()[0]!)).toContain('Réussie')
|
||||
expect(textOf(rows()[1]!)).toContain('Échec')
|
||||
})
|
||||
|
||||
it('shows the French My activity title', async () => {
|
||||
setLocale('fr')
|
||||
await mountActivity({ role: 'editor' })
|
||||
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,666 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import {
|
||||
LIST_PATH,
|
||||
SUMMARY_PATH,
|
||||
callsTo,
|
||||
chipTexts,
|
||||
clickButton,
|
||||
days,
|
||||
findButton,
|
||||
expectIcon,
|
||||
mountActivity,
|
||||
queryObject,
|
||||
rows,
|
||||
stripCards,
|
||||
textOf,
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import {
|
||||
activityItems,
|
||||
anonymous,
|
||||
apiKey,
|
||||
makeItem,
|
||||
pageOf,
|
||||
summaryDefault,
|
||||
summaryNone,
|
||||
summaryZero,
|
||||
system,
|
||||
target,
|
||||
user,
|
||||
} from './activityFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
unmountActivity()
|
||||
vi.mocked(useApi).mockReset()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function pageText() {
|
||||
return textOf(document.body)
|
||||
}
|
||||
|
||||
function stripTexts() {
|
||||
return stripCards().map(textOf)
|
||||
}
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('lists Activity between Users and Integration with the history icon and the current page', async () => {
|
||||
await mountActivity()
|
||||
|
||||
const links = Array.from(
|
||||
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
|
||||
)
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
const activity = links[4]!
|
||||
expect(activity.getAttribute('aria-current')).toBe('page')
|
||||
await expectIcon(activity, 'history')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
|
||||
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default: an owner sees the strip, the filter bar and a day-grouped table', () => {
|
||||
it('draws the header, the strip, the filter bar, the rows and the pager', async () => {
|
||||
await mountActivity()
|
||||
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
|
||||
expect(pageText()).toContain('Who did what, and when, across your site.')
|
||||
expect(findButton('Export CSV')?.disabled).toBe(false)
|
||||
expect(stripTexts()).toEqual([
|
||||
expect.stringContaining('Total actions'),
|
||||
expect.stringContaining('Active people'),
|
||||
expect.stringContaining('Failed actions'),
|
||||
expect.stringContaining('Security events'),
|
||||
])
|
||||
expect(stripTexts()[0]).toContain('1,284')
|
||||
expect(stripTexts()[0]).toContain('12% vs previous 7 days')
|
||||
expect(stripTexts()[1]).toContain('9')
|
||||
expect(stripTexts()[1]).toContain('7 people, 2 API keys')
|
||||
expect(stripTexts()[2]).toContain('0.5% of all actions')
|
||||
expect(stripTexts()[3]).toContain('11 flagged')
|
||||
await expectIcon(stripCards()[0], 'trending-up')
|
||||
await expectIcon(stripCards()[2], 'circle-x')
|
||||
await expectIcon(stripCards()[3], 'shield-alert')
|
||||
|
||||
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||
expect(search.placeholder).toBe('Search activity')
|
||||
expect(pageText()).toContain('All people')
|
||||
expect(pageText()).toContain('All categories')
|
||||
const outcome = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
|
||||
expect(outcome.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('All')
|
||||
const dates = document.body.querySelector('[role="group"][aria-label="Dates"]')!
|
||||
expect(dates.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('7 days')
|
||||
expect(chipTexts()).toEqual([])
|
||||
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–25 of 1,284',
|
||||
)
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Rows per page',
|
||||
)
|
||||
})
|
||||
|
||||
it('requests the list and the summary once each with the default query', async () => {
|
||||
await mountActivity()
|
||||
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
|
||||
})
|
||||
|
||||
it('gives every When cell the full local time with seconds as its title', async () => {
|
||||
await mountActivity()
|
||||
|
||||
const cells = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-when"]'),
|
||||
)
|
||||
expect(cells).toHaveLength(activityItems.length)
|
||||
for (const cell of cells) {
|
||||
expect(cell.textContent?.trim()).toMatch(/^\d{2}:\d{2}$/)
|
||||
expect(cell.title).toMatch(/^\d{1,2} \w{3} 2026, \d{2}:\d{2}:\d{2}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rows: the Actor cell and the sentence follow the event', () => {
|
||||
const cases: {
|
||||
name: string
|
||||
item: ActivityItem
|
||||
actor: string
|
||||
sentence: string
|
||||
badge: string
|
||||
shield: boolean
|
||||
}[] = [
|
||||
{
|
||||
name: 'user, success',
|
||||
item: makeItem({ id: 1 }),
|
||||
actor: 'Marta Alves',
|
||||
sentence: 'Published the article Spring release',
|
||||
badge: 'Succeeded',
|
||||
shield: false,
|
||||
},
|
||||
{
|
||||
name: 'api key',
|
||||
item: makeItem({
|
||||
id: 2,
|
||||
action: 'media.upload',
|
||||
category: 'media',
|
||||
actor: apiKey(7, 'Website sync'),
|
||||
target: target('image', '4', 'north-wall.jpg'),
|
||||
}),
|
||||
actor: 'Website sync API key',
|
||||
sentence: 'Uploaded the image north-wall.jpg',
|
||||
badge: 'Succeeded',
|
||||
shield: false,
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
item: makeItem({
|
||||
id: 3,
|
||||
action: 'system.media_purge',
|
||||
category: 'system',
|
||||
actor: system,
|
||||
target: null,
|
||||
params: { merged: 3, articles_updated: 5 },
|
||||
}),
|
||||
actor: 'System System',
|
||||
sentence: 'Ran the scheduled duplicate purge: 3 merged, 5 articles updated',
|
||||
badge: 'Succeeded',
|
||||
shield: false,
|
||||
},
|
||||
{
|
||||
name: 'anonymous failed login',
|
||||
item: makeItem({
|
||||
id: 4,
|
||||
action: 'auth.login_failed',
|
||||
category: 'auth',
|
||||
actor: anonymous('alex@example.com'),
|
||||
target: null,
|
||||
outcome: 'failed',
|
||||
security: true,
|
||||
}),
|
||||
actor: 'Not signed in',
|
||||
sentence: 'Failed login attempt for alex@example.com',
|
||||
badge: 'Failed',
|
||||
shield: true,
|
||||
},
|
||||
{
|
||||
name: 'failed publish',
|
||||
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
|
||||
actor: 'Inès Duarte',
|
||||
sentence: 'Failed to publish the article Spring release',
|
||||
badge: 'Failed',
|
||||
shield: false,
|
||||
},
|
||||
{
|
||||
name: 'security revoke',
|
||||
item: makeItem({
|
||||
id: 6,
|
||||
action: 'api_key.revoke',
|
||||
category: 'api_keys',
|
||||
actor: user(2, 'Jonas Weber'),
|
||||
target: target('api_key', '71', 'Mobile app'),
|
||||
security: true,
|
||||
}),
|
||||
actor: 'Jonas Weber',
|
||||
sentence: 'Revoked API key Mobile app',
|
||||
badge: 'Succeeded',
|
||||
shield: true,
|
||||
},
|
||||
]
|
||||
|
||||
it.each(cases)('$name', async ({ item, actor, sentence, badge, shield }) => {
|
||||
await mountActivity({ list: () => pageOf([item]) })
|
||||
|
||||
const row = rows()[0]!
|
||||
const cells = row.querySelectorAll('[role="cell"]')
|
||||
expect(textOf(cells[1])).toContain(actor.split(' ')[0])
|
||||
if (actor === 'Not signed in') {
|
||||
expect(textOf(cells[1])).toBe('Not signed in')
|
||||
}
|
||||
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(sentence)
|
||||
const strong = Array.from(
|
||||
row.querySelectorAll('[data-testid="activity-sentence"] .font-semibold'),
|
||||
).map(textOf)
|
||||
const name = item.action === 'auth.login_failed' ? item.actor.name : item.target?.name
|
||||
expect(strong).toEqual(name ? [name] : [])
|
||||
expect(textOf(cells[cells.length - 1])).toBe(badge)
|
||||
expect(!!row.querySelector('[aria-label="Security"]')).toBe(shield)
|
||||
expect(row.className.includes('shadow-flag')).toBe(shield)
|
||||
})
|
||||
|
||||
it('shows the actor initials, the key and the system badge', async () => {
|
||||
await mountActivity({ list: () => pageOf(activityItems) })
|
||||
|
||||
const actorCells = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="activity-actor"]'),
|
||||
)
|
||||
expect(textOf(actorCells[0])).toBe('MAMarta Alves')
|
||||
const keyCell = actorCells.find((cell) => textOf(cell).includes('Website sync'))!
|
||||
expect(textOf(keyCell)).toContain('API key')
|
||||
await expectIcon(keyCell, 'key-round')
|
||||
const systemCell = actorCells.find((cell) => textOf(cell).startsWith('System'))!
|
||||
expect(textOf(systemCell)).toBe('SystemSystem')
|
||||
await expectIcon(systemCell, 'cog')
|
||||
const anonymousCell = actorCells.find((cell) => textOf(cell) === 'Not signed in')!
|
||||
await expectIcon(anonymousCell, 'user-round-x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rows: targets', () => {
|
||||
it('strikes a deleted target through with a Deleted badge and links a living one', async () => {
|
||||
await mountActivity({
|
||||
list: () =>
|
||||
pageOf([
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'media.delete',
|
||||
category: 'media',
|
||||
target: target('image', '9', 'old-banner.png', false),
|
||||
}),
|
||||
makeItem({ id: 2 }),
|
||||
]),
|
||||
})
|
||||
|
||||
const targets = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="activity-target"]'),
|
||||
)
|
||||
expect(targets[0]!.querySelector('.line-through')?.textContent).toBe('old-banner.png')
|
||||
expect(textOf(targets[0])).toContain('Deleted')
|
||||
expect(textOf(targets[0])).toContain('Image')
|
||||
const link = targets[1]!.querySelector('a')!
|
||||
expect(link.getAttribute('href')).toBe('/article/8/edit')
|
||||
expect(textOf(targets[1])).toContain('Article')
|
||||
})
|
||||
|
||||
it('navigates from the target link without opening the details', async () => {
|
||||
await mountActivity()
|
||||
|
||||
const link = document.body.querySelector<HTMLAnchorElement>(
|
||||
'[data-testid="activity-row"] a[href="/article/8/edit"]',
|
||||
)!
|
||||
link.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/8/edit'))
|
||||
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['article', '8', '/article/8/edit'],
|
||||
['image', '4', '/media'],
|
||||
['user', '3', '/users'],
|
||||
['invite', '2', '/users'],
|
||||
['api_key', '71', '/integration/api-keys'],
|
||||
])('links a %s target to %s', async (type, id, href) => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1, target: target(type, id, 'Some name') })]),
|
||||
})
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="activity-target"] a')?.getAttribute('href'),
|
||||
).toBe(href)
|
||||
})
|
||||
|
||||
it('shows a target type with no page as plain text', async () => {
|
||||
await mountActivity({
|
||||
list: () =>
|
||||
pageOf([
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'system.update_downloaded',
|
||||
category: 'system',
|
||||
actor: system,
|
||||
target: target('release', 'v1.4.0', 'v1.4.0'),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
const cell = document.body.querySelector('[data-testid="activity-target"]')!
|
||||
expect(cell.querySelector('a')).toBeNull()
|
||||
expect(textOf(cell)).toBe('v1.4.0Release')
|
||||
})
|
||||
|
||||
it('shows a muted dash when there is no target', async () => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1, target: null, action: 'auth.login' })]),
|
||||
})
|
||||
|
||||
const cell = document.body.querySelector(
|
||||
'[data-testid="activity-row"] [role="cell"]:nth-child(4)',
|
||||
)!
|
||||
expect(textOf(cell)).toBe('—')
|
||||
expect(cell.querySelector('.text-muted')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders an unknown action code as it is and still opens the details', async () => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
|
||||
})
|
||||
|
||||
const row = rows()[0]!
|
||||
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(
|
||||
'article.archive',
|
||||
)
|
||||
row.click()
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Day grouping', () => {
|
||||
it('groups by local day in API order under separators', async () => {
|
||||
await mountActivity({
|
||||
list: () =>
|
||||
pageOf([
|
||||
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
|
||||
makeItem({ id: 2, created_at: '2026-10-03T12:10:00Z' }),
|
||||
makeItem({ id: 3, created_at: '2026-10-02T12:40:00Z' }),
|
||||
]),
|
||||
})
|
||||
|
||||
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
|
||||
const order = Array.from(
|
||||
document.body.querySelectorAll(
|
||||
'[data-testid="activity-day"], [data-testid="activity-row"]',
|
||||
),
|
||||
).map((element) => element.getAttribute('data-activity-id') ?? textOf(element))
|
||||
expect(order).toEqual(['3 Oct 2026', '1', '2', '2 Oct 2026', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading: skeletons after 150 ms', () => {
|
||||
it('draws nothing in place for 150 ms, then 4 skeleton cards and 9 skeleton rows', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const never = new Promise(() => undefined)
|
||||
await mountActivity({ list: () => never, summary: () => never })
|
||||
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
|
||||
expect(findButton('Export CSV')?.disabled).toBe(false)
|
||||
expect(
|
||||
document.body
|
||||
.querySelector('[data-testid="activity-filter-bar"] input')
|
||||
?.getAttribute('aria-label'),
|
||||
).toBe('Search activity')
|
||||
expect(document.body.querySelector('[data-testid="activity-strip-skeleton"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="activity-strip-skeleton"]'),
|
||||
).toHaveLength(4)
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="activity-skeleton-row"]'),
|
||||
).toHaveLength(9)
|
||||
expect(document.body.querySelector('[role="table"]')?.getAttribute('aria-busy')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws 5 skeleton cards below md and replaces them with the data on resolve', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
let resolveList: (value: unknown) => void = () => undefined
|
||||
const pending = new Promise((resolve) => (resolveList = resolve))
|
||||
await mountActivity({ desktop: false, list: () => pending })
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
await flushPromises()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
|
||||
).toHaveLength(5)
|
||||
|
||||
resolveList(pageOf(activityItems))
|
||||
await flushPromises()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
|
||||
).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Strip', () => {
|
||||
it('shows a calm 0 for a quiet period', async () => {
|
||||
await mountActivity({ summary: () => summaryZero })
|
||||
|
||||
const texts = stripTexts()
|
||||
expect(texts[0]).toContain('214')
|
||||
expect(texts[0]).toContain('4% vs previous 7 days')
|
||||
await expectIcon(stripCards()[0], 'trending-down')
|
||||
expect(texts[1]).toContain('5')
|
||||
expect(texts[1]).toContain('4 people, 1 API key')
|
||||
expect(texts[2]).toContain('Nothing failed')
|
||||
expect(texts[3]).toContain('Nothing to flag')
|
||||
await expectIcon(stripCards()[2], 'circle-check')
|
||||
await expectIcon(stripCards()[3], 'circle-check')
|
||||
expect(stripCards()[3]!.className).not.toContain('rc-warning-soft')
|
||||
expect(stripCards()[2]!.className).not.toContain('rc-error-soft')
|
||||
const bars = stripCards()[2]!.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="activity-bars"] span',
|
||||
)
|
||||
expect(Array.from(bars).map((bar) => bar.style.height)).toEqual(Array(7).fill('2px'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[40, 0, 'No earlier data'],
|
||||
[40, 40, 'No change'],
|
||||
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
|
||||
await mountActivity({
|
||||
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
|
||||
})
|
||||
|
||||
expect(stripTexts()[0]).toContain(subline)
|
||||
await expectIcon(stripCards()[0], 'trending-up', false)
|
||||
await expectIcon(stripCards()[0], 'trending-down', false)
|
||||
})
|
||||
|
||||
it('sizes the bars from the series on desktop and mobile', async () => {
|
||||
await mountActivity()
|
||||
const heights = Array.from(
|
||||
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
|
||||
).map((bar) => bar.style.height)
|
||||
expect(heights).toEqual(['9px', '13px', '11px', '18px', '16px', '20px', '13px'])
|
||||
unmountActivity()
|
||||
|
||||
await mountActivity({ desktop: false })
|
||||
const mobile = Array.from(
|
||||
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
|
||||
).map((bar) => bar.style.height)
|
||||
expect(mobile).toEqual(['7px', '11px', '9px', '14px', '12px', '16px', '11px'])
|
||||
})
|
||||
|
||||
it('is not drawn when the summary fails, and the list still works', async () => {
|
||||
for (const failure of [new ApiError(500), new ApiError(403), new NetworkError()]) {
|
||||
await mountActivity({
|
||||
summary: () => {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
expect(stripCards()).toHaveLength(0)
|
||||
expect(document.body.querySelector('[data-testid="activity-strip"]')).toBeNull()
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–25 of 1,284',
|
||||
)
|
||||
unmountActivity()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty states', () => {
|
||||
it('shows No activity yet with Export disabled and no table or pager', async () => {
|
||||
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
|
||||
|
||||
expect(pageText()).toContain('No activity yet')
|
||||
expect(pageText()).toContain(
|
||||
'Actions on articles, media, users and API keys will appear here.',
|
||||
)
|
||||
await expectIcon(document.body.querySelector('[data-slot="root"]'), 'history')
|
||||
expect(findButton('Export CSV')?.disabled).toBe(true)
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
|
||||
const texts = stripTexts()
|
||||
expect(texts[0]).toContain('No data yet')
|
||||
expect(texts[1]).toContain('No data yet')
|
||||
expect(texts[2]).toContain('Nothing failed')
|
||||
expect(texts[3]).toContain('Nothing to flag')
|
||||
})
|
||||
|
||||
it('offers Clear filters when filters match nothing and resets them', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
let empty = false
|
||||
await mountActivity({
|
||||
list: () => (empty ? pageOf([], { total: 0 }) : pageOf(activityItems)),
|
||||
})
|
||||
empty = true
|
||||
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||
search.value = 'sitemap'
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
|
||||
expect(chipTexts()).toEqual(['Search: sitemap'])
|
||||
expect(pageText()).toContain('No activity matches these filters')
|
||||
expect(pageText()).toContain('Try a different search or a wider date range.')
|
||||
expect(findButton('Export CSV')?.disabled).toBe(false)
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
|
||||
empty = false
|
||||
await clickButton('Clear filters')
|
||||
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
|
||||
range: '7d',
|
||||
page: '1',
|
||||
size: '25',
|
||||
})
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
expect(chipTexts()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error with retry', () => {
|
||||
it.each([
|
||||
['a 500 response', () => new ApiError(500)],
|
||||
['a 400 response', () => new ApiError(400)],
|
||||
['a network failure', () => new NetworkError()],
|
||||
])('replaces the table with an alert after %s and retries', async (_, failure) => {
|
||||
let failing = true
|
||||
await mountActivity({
|
||||
list: () => {
|
||||
if (failing) throw failure()
|
||||
return pageOf(activityItems)
|
||||
},
|
||||
})
|
||||
|
||||
expect(stripCards()).toHaveLength(4)
|
||||
expect(
|
||||
document.body
|
||||
.querySelector('[data-testid="activity-filter-bar"] input')
|
||||
?.getAttribute('aria-label'),
|
||||
).toBe('Search activity')
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain("Couldn't load activity")
|
||||
expect(textOf(alert)).toContain('Check your connection and try again.')
|
||||
expect(findButton('Export CSV')?.disabled).toBe(true)
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
|
||||
|
||||
failing = false
|
||||
await clickButton('Try again')
|
||||
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(2)
|
||||
expect(queryObject(callsTo(LIST_PATH)[1])).toEqual(queryObject(callsTo(LIST_PATH)[0]))
|
||||
expect(rows()).toHaveLength(activityItems.length)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forbidden (403)', () => {
|
||||
it('shows only the header and the lock panel, and goes home', async () => {
|
||||
await mountActivity({
|
||||
list: () => {
|
||||
throw new ApiError(403)
|
||||
},
|
||||
})
|
||||
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
|
||||
expect(findButton('Export CSV')).toBeUndefined()
|
||||
expect(stripCards()).toHaveLength(0)
|
||||
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
|
||||
await expectIcon(document.body.querySelector('main, #dashboard-content'), 'lock')
|
||||
expect(pageText()).toContain("You can't view this activity")
|
||||
expect(pageText()).toContain(
|
||||
"Your role doesn't include this page. Ask an owner or admin if you need access.",
|
||||
)
|
||||
|
||||
await clickButton('Back to home')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('shows no panel for a 401, which the API client handles globally', async () => {
|
||||
await mountActivity({
|
||||
list: () => {
|
||||
throw new ApiError(401)
|
||||
},
|
||||
})
|
||||
|
||||
expect(pageText()).not.toContain("You can't view this activity")
|
||||
expect(pageText()).not.toContain("Couldn't load activity")
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no panel for a 403 on an unverified session', async () => {
|
||||
await mountActivity({
|
||||
verified: false,
|
||||
list: () => {
|
||||
throw new ApiError(403)
|
||||
},
|
||||
})
|
||||
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(0)
|
||||
expect(pageText()).not.toContain("You can't view this activity")
|
||||
})
|
||||
})
|
||||
@@ -1503,6 +1503,7 @@ describe('Sidebar', () => {
|
||||
'/article',
|
||||
'/media',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
|
||||
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks none of the six navigation items as the current page', async () => {
|
||||
it('marks none of the seven navigation items as the current page', async () => {
|
||||
await mountApp()
|
||||
|
||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(6)
|
||||
expect(links).toHaveLength(7)
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type {
|
||||
ActivityActor,
|
||||
ActivityChange,
|
||||
ActivityItem,
|
||||
ActivityPage,
|
||||
ActivitySummary,
|
||||
ActivityTarget,
|
||||
} from '@/lib/activity'
|
||||
|
||||
const LONG_TITLE =
|
||||
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
|
||||
|
||||
const WEB_IP = '203.0.113.24'
|
||||
const WEB_AGENT = 'Firefox 131 on macOS'
|
||||
|
||||
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
|
||||
export const apiKey = (id: number, name: string): ActivityActor => ({
|
||||
type: 'api_key',
|
||||
id,
|
||||
name,
|
||||
})
|
||||
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
|
||||
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
|
||||
|
||||
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
exists,
|
||||
})
|
||||
|
||||
export function makeItem(overrides: Partial<ActivityItem> & { id: number }): ActivityItem {
|
||||
return {
|
||||
created_at: '2026-10-03T12:30:00Z',
|
||||
action: 'article.publish',
|
||||
category: 'articles',
|
||||
actor: user(3, 'Marta Alves'),
|
||||
target: target('article', '8', 'Spring release'),
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
security: false,
|
||||
source: 'web',
|
||||
ip: WEB_IP,
|
||||
user_agent: WEB_AGENT,
|
||||
params: {},
|
||||
changes: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const titleChange: ActivityChange = {
|
||||
field: 'title',
|
||||
kind: 'text',
|
||||
before: 'Why we reuse floorboards',
|
||||
after: 'Why we reuse original floorboards',
|
||||
}
|
||||
const bodyChange: ActivityChange = { field: 'body', kind: 'body', delta: 312 }
|
||||
|
||||
export const activityItems: ActivityItem[] = [
|
||||
makeItem({
|
||||
id: 1,
|
||||
created_at: '2026-10-03T12:58:00Z',
|
||||
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
|
||||
}),
|
||||
makeItem({
|
||||
id: 2,
|
||||
created_at: '2026-10-03T12:45:00Z',
|
||||
action: 'article.edit',
|
||||
target: target('article', '5', 'Why we reuse original floorboards'),
|
||||
changes: [titleChange, bodyChange],
|
||||
}),
|
||||
makeItem({
|
||||
id: 3,
|
||||
created_at: '2026-10-03T12:40:00Z',
|
||||
action: 'auth.login_failed',
|
||||
category: 'auth',
|
||||
actor: anonymous('alex@example.com'),
|
||||
target: null,
|
||||
outcome: 'failed',
|
||||
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
|
||||
security: true,
|
||||
ip: '198.51.100.77',
|
||||
user_agent: 'Chrome 129 on Windows',
|
||||
}),
|
||||
makeItem({
|
||||
id: 4,
|
||||
created_at: '2026-10-03T12:35:00Z',
|
||||
action: 'api_key.revoke',
|
||||
category: 'api_keys',
|
||||
actor: user(2, 'Jonas Weber'),
|
||||
target: target('api_key', '71', 'Mobile app'),
|
||||
security: true,
|
||||
}),
|
||||
makeItem({
|
||||
id: 5,
|
||||
created_at: '2026-10-03T12:20:00Z',
|
||||
action: 'system.media_purge',
|
||||
category: 'system',
|
||||
actor: system,
|
||||
target: null,
|
||||
source: 'system',
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
params: { merged: 3, articles_updated: 5 },
|
||||
}),
|
||||
makeItem({
|
||||
id: 6,
|
||||
created_at: '2026-10-03T12:10:00Z',
|
||||
action: 'media.upload',
|
||||
category: 'media',
|
||||
actor: apiKey(7, 'Website sync'),
|
||||
target: target('image', '4', 'north-wall.jpg'),
|
||||
source: 'api_key',
|
||||
}),
|
||||
makeItem({
|
||||
id: 7,
|
||||
created_at: '2026-10-03T12:05:00Z',
|
||||
action: 'media.delete',
|
||||
category: 'media',
|
||||
actor: user(3, 'Marta Alves'),
|
||||
target: target('image', '9', 'old-banner.png', false),
|
||||
}),
|
||||
makeItem({
|
||||
id: 8,
|
||||
created_at: '2026-10-03T12:00:00Z',
|
||||
outcome: 'failed',
|
||||
failure_reason: 'The article has no cover image. Add one, then publish again.',
|
||||
actor: user(4, 'Inès Duarte'),
|
||||
target: target('article', '6', LONG_TITLE),
|
||||
}),
|
||||
makeItem({
|
||||
id: 9,
|
||||
created_at: '2026-10-02T12:50:00Z',
|
||||
action: 'system.activity_cleanup',
|
||||
category: 'system',
|
||||
actor: system,
|
||||
target: null,
|
||||
source: 'system',
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
params: { removed: 3 },
|
||||
}),
|
||||
makeItem({
|
||||
id: 10,
|
||||
created_at: '2026-10-02T12:40:00Z',
|
||||
action: 'user.update_profile',
|
||||
category: 'users',
|
||||
actor: user(4, 'Inès Duarte'),
|
||||
target: target('user', '8', 'Sam Rivera'),
|
||||
changes: [
|
||||
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
export const LONG_VALUE = 'Renovation notes and practical guides from the Renew studio. '.repeat(3)
|
||||
|
||||
export const TOTAL = 1284
|
||||
export const SIZE = 25
|
||||
|
||||
export function pageOf(
|
||||
items: ActivityItem[],
|
||||
{ total = TOTAL, page = 1, size = SIZE }: { total?: number; page?: number; size?: number } = {},
|
||||
): ActivityPage {
|
||||
return { items, total, page, size }
|
||||
}
|
||||
|
||||
export const summaryDefault: ActivitySummary = {
|
||||
total: 1284,
|
||||
previous_total: 1146,
|
||||
active_people: { users: 7, api_keys: 2 },
|
||||
failed: 6,
|
||||
security: 11,
|
||||
series: {
|
||||
total: [4, 6, 5, 8, 7, 9, 6],
|
||||
active: [5, 6, 6, 7, 7, 8, 9],
|
||||
failed: [0, 1, 0, 2, 1, 0, 2],
|
||||
security: [1, 0, 2, 1, 3, 2, 2],
|
||||
},
|
||||
}
|
||||
|
||||
export const summaryZero: ActivitySummary = {
|
||||
total: 214,
|
||||
previous_total: 223,
|
||||
active_people: { users: 4, api_keys: 1 },
|
||||
failed: 0,
|
||||
security: 0,
|
||||
series: {
|
||||
total: [3, 5, 2, 6, 4, 1, 2],
|
||||
active: [2, 3, 2, 3, 2, 1, 2],
|
||||
failed: [0, 0, 0, 0, 0, 0, 0],
|
||||
security: [0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
}
|
||||
|
||||
const ZEROS = [0, 0, 0, 0, 0, 0, 0]
|
||||
|
||||
export const summaryNone: ActivitySummary = {
|
||||
total: 0,
|
||||
previous_total: 0,
|
||||
active_people: { users: 0, api_keys: 0 },
|
||||
failed: 0,
|
||||
security: 0,
|
||||
series: { total: ZEROS, active: ZEROS, failed: ZEROS, security: ZEROS },
|
||||
}
|
||||
@@ -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