Files
ghtracker/src/App.test.ts
T
lukaszraczyloandGitHub 8cbc495dc7 feat: refresh a single repository from its own button (#2)
* feat(refresh): refresh a single repository with POST /refresh?repo=

The endpoint refreshes one configured repository and returns the full state plus the repo name.
Each repository has a 60-second cooldown that is separate from the full-refresh limit.
A running full refresh or a refresh of the same repository returns 409.
An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same.
The collector reuses its fetch path and replaces only that repository.

* feat(ui): add a refresh button beside each repository

The button appears in the project list and in each attention group header.
It stays hidden when the server disables manual refresh.
The button shows a spinner while its repository refreshes and reports refusals in the notice line.
The store swaps in the returned state, so no extra fetch follows.
2026-10-02 23:16:22 +01:00

141 lines
5.7 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
import { mixedState, repo, state } from '@/test/fixtures'
import type { TrackerState } from '@/lib/types'
function mountWith(body: TrackerState | Error) {
vi.stubGlobal(
'fetch',
vi.fn(() =>
body instanceof Error
? Promise.reject(body)
: Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
),
)
return mount(App, { global: { plugins: [createPinia()] } })
}
describe('App', () => {
afterEach(() => vi.unstubAllGlobals())
it('shows the overview, only the problems and every project', async () => {
const w = mountWith(mixedState())
await flushPromises()
expect(w.get('[data-test=headline]').text()).toContain('2')
expect(w.findAll('[data-test=segment]')).toHaveLength(2)
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=row]')).toHaveLength(2)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('shows an all-clear state without repo blocks', async () => {
const w = mountWith(state({ repos: [repo()] }))
await flushPromises()
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.findAll('[data-test=project]')).toHaveLength(1)
})
it('focuses one project from the strip and clears the focus', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.findAll('[data-test=segment]')[1].trigger('click')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=empty]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('narrows the list as you type and restores it when cleared', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=search]').setValue('zzz-no-match')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=no-projects]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('applies a kind filter from the toolbar', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=kind-workflow]').trigger('click')
expect(w.findAll('[data-test=row]')).toHaveLength(1)
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
})
it('waits while the server has not finished its first refresh', async () => {
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
await flushPromises()
expect(w.find('[data-test=waiting]').exists()).toBe(true)
expect(w.get('[data-test=loading-detail]').text()).toContain('4 of 10 scanned')
expect(w.find('[data-test=headline]').exists()).toBe(false)
})
it('surfaces a connection error', async () => {
const w = mountWith(new Error('offline'))
await flushPromises()
expect(w.get('[data-test=error]').text()).toContain('offline')
})
it('offers a rescan only when the server allows it', async () => {
const on = mountWith(state({ manualRefresh: true, repos: [repo()] }))
await flushPromises()
expect(on.find('[data-test=rescan]').exists()).toBe(true)
const off = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
it('refreshes one repo from its button, shows the new state and announces it', async () => {
const first = state({
manualRefresh: true,
repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })],
})
const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] }
const fetchMock = vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' })
expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r')
})
it('shows the refusal message when a repo is refreshed too soon', async () => {
const body = state({ manualRefresh: true, repos: [repo()] })
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
init?.method === 'POST'
? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } })
: new Response(JSON.stringify(body), { status: 200 }),
),
),
)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s')
expect(
w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'),
).toBeUndefined()
})
it('hides the repo buttons when the server disables manual refresh', async () => {
const w = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(w.find('[data-test=repo-refresh]').exists()).toBe(false)
})
})