Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, render, screen, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import * as dashboard from '../../api/dashboard'
|
|
import * as settings from '../../api/settings'
|
|
import * as apps from '../../api/apps'
|
|
import * as zonelists from '../../api/zonelists'
|
|
import { SlotProvider } from '../../app/ChromeSlot'
|
|
import { Overview, blockingChart } from './Overview'
|
|
|
|
/** Your Rules: each number with the word under it, in the card's two columns. */
|
|
function rulePairs() {
|
|
const text = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
|
|
const [numbers, words] = [...text.children].slice(0, 2).map((row) => [...row.children].map((c) => c.textContent))
|
|
return numbers.map((n, i) => [n, words[i]])
|
|
}
|
|
|
|
/* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a
|
|
new reference, so identity across renders is what the tests look at. */
|
|
const drawn = vi.hoisted(() => new Map<string, unknown[]>())
|
|
|
|
const legends = vi.hoisted(() => new Map<string, unknown>())
|
|
/* What each chart was told to hide, and whether it was told not to draw its own legend. */
|
|
const hiddenOf = vi.hoisted(() => new Map<string, { hidden?: ReadonlySet<string>; separate?: boolean }>())
|
|
|
|
vi.mock('../dashboard/Chart', () => ({
|
|
Chart: ({ aria, data, legendOrder, hidden, separateLegend }: {
|
|
aria: string; data: unknown; legendOrder?: readonly string[]; hidden?: ReadonlySet<string>; separateLegend?: boolean
|
|
}) => {
|
|
const key = aria.startsWith('Blocked share') ? 'ring' : 'bars'
|
|
drawn.set(key, [...(drawn.get(key) ?? []), data])
|
|
legends.set(key, legendOrder)
|
|
hiddenOf.set(key, { hidden, separate: separateLegend })
|
|
return <div role="img" aria-label={aria} />
|
|
},
|
|
}))
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
localStorage.clear()
|
|
drawn.clear()
|
|
})
|
|
|
|
const STATS = {
|
|
totalQueries: 20354, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
|
|
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 5310, totalDropped: 0,
|
|
totalClients: 11, zones: 5, cachedEntries: 0, allowedZones: 1, blockedZones: 1, allowListZones: 0, blockListZones: 74779,
|
|
}
|
|
const MAIN = { labels: ['a', 'b'], datasets: [
|
|
{ label: 'Total', data: [10, 30] }, { label: 'No Error', data: [5, 5] }, { label: 'Blocked', data: [4, 6] },
|
|
] }
|
|
const OK = {
|
|
kind: 'ok' as const,
|
|
data: {
|
|
stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN,
|
|
protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [],
|
|
},
|
|
}
|
|
|
|
function serve() {
|
|
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as never)
|
|
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
|
|
return vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
|
}
|
|
|
|
describe('blockingChart', () => {
|
|
/* Chart.js stacks in dataset order from the axis up: Blocked first puts it on the
|
|
baseline, as drawn, where its own series reads off a common zero. */
|
|
it('keeps only what got through and what was blocked, Blocked at the bottom of the stack', () => {
|
|
expect(blockingChart(MAIN)).toEqual({
|
|
labels: ['a', 'b'],
|
|
datasets: [{ label: 'Blocked', data: [4, 6] }, { label: 'Allowed', data: [6, 24] }],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Overview', () => {
|
|
/* As drawn, and as the bars stack: the ring starts at the top with Blocked. */
|
|
it('the ring draws Blocked first, then what got through', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByRole('img', { name: 'Blocked share: 26.09%' })
|
|
expect(drawn.get('ring')?.at(-1)).toEqual({
|
|
labels: ['Blocked', 'Allowed'],
|
|
datasets: [{ label: 'Share', data: [5310, 20354 - 5310] }],
|
|
})
|
|
})
|
|
|
|
it('the bars read Allowed, then Blocked, in their legend', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
|
|
expect(legends.get('bars')).toEqual(['Allowed', 'Blocked'])
|
|
})
|
|
|
|
it('the bars carry the Dashboard legend: each series with its total, and a click switches it off', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
|
|
expect(hiddenOf.get('bars')?.separate).toBe(true)
|
|
/* MAIN: Total 10 + 30, Blocked 4 + 6, so 30 got through. In BAR_LEGEND's order. */
|
|
const allowed = screen.getByRole('button', { name: 'Allowed 30' })
|
|
const blocked = screen.getByRole('button', { name: 'Blocked 10' })
|
|
expect(allowed.compareDocumentPosition(blocked) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
expect(allowed).toHaveAttribute('aria-pressed', 'true')
|
|
await userEvent.click(blocked)
|
|
expect(blocked).toHaveAttribute('aria-pressed', 'false')
|
|
expect([...(hiddenOf.get('bars')?.hidden ?? [])]).toEqual(['Blocked'])
|
|
})
|
|
|
|
it('draws the four figures, Blocked with its share', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
expect(await screen.findByText('20,354')).toBeInTheDocument()
|
|
expect(screen.getByText('5,310')).toBeInTheDocument()
|
|
/* The ring writes the same share in its hole: the figure is looked for in its card. */
|
|
expect(within(screen.getByText('5,310').parentElement!).getByText('26.09% of total')).toBeInTheDocument()
|
|
expect(screen.getByText('74,779')).toBeInTheDocument()
|
|
expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']])
|
|
})
|
|
|
|
it('draws the stacked bars and the blocked share', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
expect(await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })).toBeInTheDocument()
|
|
expect(screen.getByRole('img', { name: 'Blocked share: 26.09%' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('the period asks the server again', async () => {
|
|
const spy = serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
|
|
expect(spy).toHaveBeenLastCalledWith('T', 'LastDay', undefined, 'cluster')
|
|
})
|
|
|
|
it('offers the five fixed periods and not Custom', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
const group = screen.getByRole('group', { name: 'Period' })
|
|
expect([...group.querySelectorAll('button')].map((b) => b.textContent)).toEqual([
|
|
'Last Hour', 'Last Day', 'Last Week', 'Last Month', 'Last Year',
|
|
])
|
|
})
|
|
|
|
it('reads the node the Dashboard remembers', async () => {
|
|
localStorage.setItem('dashboardClusterNode', 'dns2.example')
|
|
const spy = serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
expect(spy).toHaveBeenCalledWith('T', 'LastHour', undefined, 'dns2.example')
|
|
})
|
|
|
|
it('with the cluster aggregate, Recently Blocked names the node it reads', async () => {
|
|
serve()
|
|
render(
|
|
<Overview
|
|
token="T"
|
|
permissions={undefined}
|
|
nodes={[{ name: 'dns1.example', type: 'Primary' }]}
|
|
clusterInitialised
|
|
serverDomain="dns1.example"
|
|
/>,
|
|
)
|
|
await screen.findByText('20,354')
|
|
expect(screen.getByRole('heading', { name: 'Recently Blocked on dns1.example' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('without a cluster, Recently Blocked names no node', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} serverDomain="dns1.example" />)
|
|
await screen.findByText('20,354')
|
|
expect(screen.getByRole('heading', { name: 'Recently Blocked' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('without Dashboard.canView the figures are locked and nothing is asked', () => {
|
|
const spy = serve()
|
|
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
|
|
expect(screen.getAllByText('Requires Dashboard: View').length).toBeGreaterThan(0)
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
/* A panel without its view permission keeps its place and its title, with the padlock,
|
|
so the screen does not change shape with who looks. */
|
|
it('without Dashboard.canView the charts and both tops keep their place, locked', () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
|
|
for (const title of ['Statistics', 'Queries over time', 'Blocked share', 'Top Blocked Domains', 'Top Domains']) {
|
|
/* The heading sits in the panel's header; the panel is the header's parent. */
|
|
const panel = screen.getByRole('heading', { name: title }).parentElement!.parentElement!
|
|
expect(within(panel).getByText('Requires Dashboard: View')).toBeInTheDocument()
|
|
}
|
|
expect(screen.queryByRole('table')).toBeNull()
|
|
})
|
|
|
|
/* Every figure is localised; 10,000 rules are not drawn `10000`. */
|
|
it('Your Rules is localised like the other figures', async () => {
|
|
serve().mockResolvedValue({ ...OK, data: { ...OK.data, stats: { ...STATS, blockedZones: 10000, allowedZones: 1234 } } })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText((10000).toLocaleString())
|
|
expect(rulePairs()).toEqual([[(10000).toLocaleString(), 'blocked'], [(1234).toLocaleString(), 'allowed']])
|
|
})
|
|
|
|
it('while the figures travel the top tables do not claim there are no domains', () => {
|
|
serve().mockReturnValue(new Promise(() => {}))
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
|
|
expect(screen.getAllByText('—').length).toBe(4)
|
|
})
|
|
|
|
it('a first read that fails says so with the message from the server', async () => {
|
|
serve().mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
|
|
expect(screen.getAllByText('Could not load this data.').length).toBeGreaterThan(0)
|
|
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('a refresh that fails keeps the figures and says they are stale', async () => {
|
|
const spy = serve()
|
|
vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'ok', data: {} })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
|
|
await userEvent.type(screen.getByLabelText('Domain'), 'ads.example.com{Enter}')
|
|
expect(await screen.findByText('Could not refresh.')).toBeInTheDocument()
|
|
expect(screen.getByText('20,354')).toBeInTheDocument()
|
|
spy.mockResolvedValue(OK)
|
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
expect(await screen.findByText('20,354')).toBeInTheDocument()
|
|
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('a new period that fails does not keep the figures of the previous one', async () => {
|
|
const spy = serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
|
|
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
|
|
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
|
|
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('a render for another reason does not hand the charts new data', async () => {
|
|
serve()
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await screen.findByText('20,354')
|
|
const bars = drawn.get('bars')!.at(-1)
|
|
const ring = drawn.get('ring')!.at(-1)
|
|
const renders = drawn.get('bars')!.length
|
|
// An empty Block raises a notice: the screen renders again, the stats do not change.
|
|
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
|
|
expect(await screen.findByText('Please enter a domain name to block.')).toBeInTheDocument()
|
|
expect(drawn.get('bars')!.length).toBeGreaterThan(renders)
|
|
expect(drawn.get('bars')!.at(-1)).toBe(bars)
|
|
expect(drawn.get('ring')!.at(-1)).toBe(ring)
|
|
})
|
|
|
|
it('choosing a node asks that node and remembers it for the Dashboard', async () => {
|
|
const spy = serve()
|
|
render(
|
|
<SlotProvider>
|
|
{(slot) => (
|
|
<>
|
|
<div ref={slot} />
|
|
<Overview
|
|
token="T"
|
|
permissions={undefined}
|
|
nodes={[{ name: 'dns1.example', type: 'Primary' }, { name: 'dns2.example', type: 'Secondary' }]}
|
|
clusterInitialised
|
|
/>
|
|
</>
|
|
)}
|
|
</SlotProvider>,
|
|
)
|
|
await screen.findByText('20,354')
|
|
await userEvent.click(screen.getByRole('combobox', { name: 'Node' }))
|
|
await userEvent.click(screen.getByRole('option', { name: 'dns2.example (secondary)' }))
|
|
expect(localStorage.getItem('dashboardClusterNode')).toBe('dns2.example')
|
|
expect(spy).toHaveBeenLastCalledWith('T', 'LastHour', undefined, 'dns2.example')
|
|
})
|
|
|
|
it('a slow answer to an earlier period does not overwrite a later one', async () => {
|
|
const spy = serve()
|
|
let answerFirst: (v: typeof OK) => void = () => {}
|
|
spy.mockImplementationOnce(() => new Promise((resolve) => { answerFirst = resolve }))
|
|
spy.mockResolvedValueOnce({ kind: 'ok', data: { ...OK.data, stats: { ...STATS, totalQueries: 99999 } } })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
|
|
expect(await screen.findByText('99,999')).toBeInTheDocument()
|
|
await act(async () => answerFirst(OK))
|
|
expect(screen.getByText('99,999')).toBeInTheDocument()
|
|
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('a period with no queries draws zero and no empty chart', async () => {
|
|
const zero = { ...STATS, totalQueries: 0, totalBlocked: 0 }
|
|
const flat = { labels: ['a'], datasets: [{ label: 'Total', data: [0] }, { label: 'Blocked', data: [0] }] }
|
|
serve().mockResolvedValue({ kind: 'ok', data: { ...OK.data, stats: zero, mainChartData: flat } })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
expect(await screen.findByText('0% of total')).toBeInTheDocument()
|
|
expect(screen.getAllByText('No queries for this period.').length).toBe(2)
|
|
expect(screen.queryByRole('img', { name: 'Allowed and blocked queries over time' })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
/* Last Hour in real time, as on the Dashboard: the same `stats/get`, the same live
|
|
figures over it (dashboard/useLive.ts), the same minute reload. */
|
|
describe('Overview: Last Hour in real time', () => {
|
|
const counters = (q: number, blocked: number) => ({
|
|
kind: 'ok' as const,
|
|
data: {
|
|
uptimestamp: 'u1',
|
|
lifetimeCounters: {
|
|
totalQueries: q, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
|
|
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: blocked, totalDropped: 0,
|
|
totalClients: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
it('Total Queries and Blocked move with the server between reloads', async () => {
|
|
vi.useFakeTimers()
|
|
serve()
|
|
vi.spyOn(dashboard, 'getMetrics').mockResolvedValueOnce(counters(1000, 50)).mockResolvedValue(counters(1004, 52))
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
|
expect(screen.getByText((20354).toLocaleString())).toBeInTheDocument()
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
|
expect(screen.getByText((20358).toLocaleString())).toBeInTheDocument()
|
|
expect(screen.getByText((5312).toLocaleString())).toBeInTheDocument()
|
|
})
|
|
|
|
it('reloads a minute after each answer, waits while the tab is hidden, and reloads on return', async () => {
|
|
vi.useFakeTimers()
|
|
let hidden = false
|
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden })
|
|
const spy = serve()
|
|
vi.spyOn(dashboard, 'getMetrics').mockResolvedValue({ kind: 'error', message: 'not here' })
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
|
|
expect(spy).toHaveBeenCalledTimes(2)
|
|
|
|
hidden = true
|
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(180_000) })
|
|
expect(spy).toHaveBeenCalledTimes(2)
|
|
|
|
hidden = false
|
|
await act(async () => {
|
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
})
|
|
expect(spy).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('other periods neither reload nor read the counters', async () => {
|
|
vi.useFakeTimers()
|
|
const spy = serve()
|
|
const live = vi.spyOn(dashboard, 'getMetrics').mockResolvedValue(counters(1, 0))
|
|
render(<Overview token="T" permissions={undefined} />)
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
|
await act(async () => { screen.getByRole('button', { name: 'Last Day' }).click() })
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
|
spy.mockClear()
|
|
live.mockClear()
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(130_000) })
|
|
expect(spy).not.toHaveBeenCalled()
|
|
expect(live).not.toHaveBeenCalled()
|
|
})
|
|
})
|