From b25486477a785d201edf6d22982b4345467447db Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 1 Aug 2026 18:27:26 +0100 Subject: [PATCH] test: cover dashboard repository filter --- .gitignore | 2 + test/nuxt/dashboard.spec.ts | 161 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 test/nuxt/dashboard.spec.ts diff --git a/.gitignore b/.gitignore index c167977..69cf167 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ logs # Testing test-results +.vitest-attachments +test/**/__screenshots__ # Test coverage coverage/ diff --git a/test/nuxt/dashboard.spec.ts b/test/nuxt/dashboard.spec.ts new file mode 100644 index 0000000..67617f5 --- /dev/null +++ b/test/nuxt/dashboard.spec.ts @@ -0,0 +1,161 @@ +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import type { VueWrapper } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import type { DashboardPayload, DashboardRepo } from '#server/api/me/dashboard.get' +// @ts-ignore -- the lint pass type-checks without the Vue SFC plugin, so it +// cannot resolve this import. `pnpm test:types` (vue-tsc) resolves it fine. +import Dashboard from '~/pages/dashboard.vue' + +function repo(overrides: Partial & Pick): DashboardRepo { + return { + githubRepoId: overrides.id * 100, + tangledRepoDid: 'did:plc:example', + tangledFullName: `example.tld/${overrides.githubFullName.split('/')[1]}`, + knot: 'knot.tangled.org', + status: 'active', + lastError: null, + disabledAt: null, + lastSyncedRefs: { 'refs/heads/main': 'a'.repeat(40) }, + lastSyncedAt: '2026-07-01T00:00:00.000Z', + refCount: 1, + health: { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false }, + ...overrides, + } +} + +const repos: DashboardRepo[] = [ + repo({ id: 1, githubFullName: 'example/alpha' }), + repo({ id: 2, githubFullName: 'example/bravo' }), + repo({ + id: 3, + githubFullName: 'example/charlie', + disabledAt: '2026-07-02T00:00:00.000Z', + health: { state: 'paused', message: 'Sync is paused. Re-enable to resume mirroring.', needsAttention: false }, + }), +] + +registerEndpoint('/api/me/dashboard', (): DashboardPayload => ({ + did: 'did:plc:example', + handle: 'example.tld', + installation: { id: 1, accountLogin: 'example', accountType: 'User', suspendedAt: null }, + hasSshKey: true, + sshKey: { publicKey: 'ssh-ed25519 AAAA', createdAt: '2026-07-01T00:00:00.000Z', rotatedAt: null }, + repos, + summary: { total: 3, ok: 2, behind: 0, enrolling: 0, needsAttention: 0 }, +})) + +registerEndpoint('/api/me/accounts', () => ({ + active: 'did:plc:example', + accounts: [{ did: 'did:plc:example', installationId: 1, handle: 'example.tld', accountLogin: 'example', active: true }], +})) + +// The `authenticated` middleware probes this on every navigation, and selecting +// a filter navigates. +registerEndpoint('/api/me/whoami', () => ({ + did: 'did:plc:example', + installationId: 1, + handle: 'example.tld', +})) + +function filters(wrapper: VueWrapper) { + return wrapper.findAll('.repo-filter__button') +} + +function names(wrapper: VueWrapper) { + return wrapper.findAll('.repo__name').map(a => a.text()) +} + +describe('dashboard repository filter', () => { + async function mountDashboard(expected = 3, options: Parameters[1] = {}) { + const wrapper = await mountSuspended(Dashboard, { route: '/dashboard', ...options }) + await vi.waitFor(() => expect(wrapper.findAll('.repo')).toHaveLength(expected)) + return wrapper + } + + it('should label each filter with its repository count', async () => { + const wrapper = await mountDashboard() + expect(filters(wrapper).map(b => b.text())).toStrictEqual([ + 'all (3)', + 'enabled (2)', + 'disabled (1)', + ]) + }) + + it('should narrow the list to disabled repositories', async () => { + const wrapper = await mountDashboard() + + await filters(wrapper)[2]!.trigger('click') + + // Selecting a filter navigates, so the list settles asynchronously. + await vi.waitFor(() => expect(names(wrapper)).toStrictEqual(['example/charlie'])) + expect(wrapper.find('.repos__heading h2').text()).toBe('repositories (1 of 3)') + }) + + it('should narrow the list to enabled repositories', async () => { + const wrapper = await mountDashboard() + + await filters(wrapper)[1]!.trigger('click') + + await vi.waitFor(() => expect(names(wrapper)).toStrictEqual(['example/alpha', 'example/bravo'])) + }) + + it('should read the initial filter from the url', async () => { + const wrapper = await mountDashboard(1, { route: '/dashboard?filter=disabled' }) + expect(names(wrapper)).toStrictEqual(['example/charlie']) + + await useRouter().replace('/dashboard') + }) + + it('should reflect the active filter in the url, and drop the param for `all`', async () => { + const wrapper = await mountDashboard() + const router = useRouter() + + await filters(wrapper)[1]!.trigger('click') + await vi.waitFor(() => expect(router.currentRoute.value.query.filter).toBe('enabled')) + + await filters(wrapper)[0]!.trigger('click') + await vi.waitFor(() => expect(router.currentRoute.value.query.filter).toBeUndefined()) + + await router.replace('/dashboard') + }) + + it('should fall back to `all` for an unrecognised filter', async () => { + const wrapper = await mountDashboard(3, { route: '/dashboard?filter=bogus' }) + + expect(wrapper.find('.repos__heading h2').text()).toBe('repositories (3)') + expect(filters(wrapper)[0]!.attributes('aria-checked')).toBe('true') + + await useRouter().replace('/dashboard') + }) + + it('should expose the filters as a single-select radiogroup', async () => { + const wrapper = await mountDashboard() + + expect(wrapper.find('.repo-filter').attributes('role')).toBe('radiogroup') + expect(filters(wrapper).map(b => b.attributes('role'))).toStrictEqual(['radio', 'radio', 'radio']) + expect(filters(wrapper).map(b => b.attributes('aria-checked'))).toStrictEqual(['true', 'false', 'false']) + // Roving tabindex: the group is a single tab stop. + expect(filters(wrapper).map(b => b.attributes('tabindex'))).toStrictEqual(['0', '-1', '-1']) + }) + + it('should move selection with arrow keys, wrapping at both ends', async () => { + // Attached, so `focus()` on the newly selected radio is observable. + const wrapper = await mountDashboard(3, { attachTo: document.body }) + const checked = () => filters(wrapper).map(b => b.attributes('aria-checked')) + + await filters(wrapper)[0]!.trigger('keydown', { key: 'ArrowRight' }) + await vi.waitFor(() => expect(checked()).toStrictEqual(['false', 'true', 'false'])) + expect(document.activeElement).toBe(filters(wrapper)[1]!.element) + + await filters(wrapper)[1]!.trigger('keydown', { key: 'ArrowLeft' }) + await vi.waitFor(() => expect(checked()).toStrictEqual(['true', 'false', 'false'])) + + await filters(wrapper)[0]!.trigger('keydown', { key: 'ArrowLeft' }) + await vi.waitFor(() => expect(checked()).toStrictEqual(['false', 'false', 'true'])) + + await filters(wrapper)[2]!.trigger('keydown', { key: 'ArrowRight' }) + await vi.waitFor(() => expect(checked()).toStrictEqual(['true', 'false', 'false'])) + + await useRouter().replace('/dashboard') + }) +}) -- 2.51.2