Shell: collapsible rail, DNS/Server groups with pins, always-on header with breadcrumbs and server status, Ctrl/Cmd+K command palette. Cache: left column is a lazily expanded tree of every zone, right column the selected node's records; both columns are ReUI Frames. Styles: dark variant follows data-theme (field and outline fills), 32 px selects, 36 px segmented strip, 40 px caption table heads, 56 px rows, 20/24/32 page padding. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
219 lines
9.1 KiB
TypeScript
219 lines
9.1 KiB
TypeScript
/*
|
|
The domain-tree round of 2026-09-07, put on the bench.
|
|
|
|
What this file guards is the four things the round exists to fix, plus the two it
|
|
was not allowed to break. Everything here is measured on the DOM the screen
|
|
actually renders, not on the drawing: the drawing is what was agreed, this is
|
|
what shipped.
|
|
*/
|
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
import { render, screen, within } from '@testing-library/react'
|
|
import { Cache, Lists } from './Lists'
|
|
import * as api from '../../api/zonelists'
|
|
import * as client from '../../api/client'
|
|
import type { ListNode } from '../../api/zonelists'
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
function node(p: Partial<ListNode> = {}): ListNode {
|
|
return { domain: '', zones: [], records: [], ...p }
|
|
}
|
|
|
|
function withNode(...nodes: ListNode[]) {
|
|
const spy = vi.spyOn(api, 'listNode')
|
|
for (const n of nodes) spy.mockResolvedValueOnce({ kind: 'ok', data: n })
|
|
spy.mockResolvedValue({ kind: 'ok', data: nodes[nodes.length - 1] ?? node() })
|
|
return spy
|
|
}
|
|
|
|
const NS = {
|
|
name: 'ads.example.net',
|
|
type: 'NS' as const,
|
|
ttl: 14400,
|
|
ttlString: '4h',
|
|
rData: { nameServer: 'dev' },
|
|
dnssecStatus: 'Unknown',
|
|
lastUsedOn: '0001-01-01T00:00:00',
|
|
}
|
|
|
|
describe('the path says where you are', () => {
|
|
/* The third problem: the current node used to appear only inside
|
|
`N records at <node>`, with no chain, so on a three-level tree there was no
|
|
telling what it hung off. */
|
|
it('it writes the whole chain, root first and the open node last', async () => {
|
|
withNode(node({ domain: 'ads.example.net', records: [NS] }))
|
|
render(<Lists list="allowed" token="t" embedded />)
|
|
await screen.findByText(/2 records at|1 records at/)
|
|
|
|
const path = screen.getByText('Node').parentElement!
|
|
expect(path).toHaveTextContent('<ROOT>')
|
|
expect(path).toHaveTextContent('net')
|
|
expect(path).toHaveTextContent('example.net')
|
|
expect(path).toHaveTextContent('ads.example.net')
|
|
})
|
|
|
|
it('at the root it says only the root', async () => {
|
|
withNode(node({ zones: ['com'] }))
|
|
render(<Cache token="t" />)
|
|
await screen.findByText('com')
|
|
expect(screen.getByText('Node').parentElement).toHaveTextContent('<ROOT>')
|
|
})
|
|
|
|
/*
|
|
It does NOT navigate, and that is the point: navigating is the tree's job and a
|
|
second way to do it would be behaviour upstream does not have. If somebody ever
|
|
makes these clickable, this is what says no.
|
|
*/
|
|
it('nothing in it is clickable', async () => {
|
|
withNode(node({ domain: 'ads.example.net', records: [NS] }))
|
|
render(<Lists list="allowed" token="t" embedded />)
|
|
await screen.findByText('Node')
|
|
|
|
const path = screen.getByText('Node').parentElement!
|
|
expect(within(path).queryByRole('link')).toBeNull()
|
|
expect(within(path).queryByRole('button')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('cache and policy are told apart', () => {
|
|
/* Problem 2: three screens, one component, and nothing said which was which.
|
|
The second line is one of the three channels, and the only one a test can
|
|
read; the icon and the colour are checked in the 4x4 matrix. */
|
|
/* Allowed and Blocked are drawn `embedded` now, inside Blocking's Rules tab:
|
|
without the section header, the band is the only thing naming them. */
|
|
it.each([
|
|
['cache' as const, false, 'Cache', 'What the server resolved and stored'],
|
|
['allowed' as const, true, 'Allowed', 'Domains the administrator lets through'],
|
|
['blocked' as const, true, 'Blocked', 'Domains the administrator blocks'],
|
|
])('%#: the identity band names the screen and what it holds', async (list, embedded, title, sub) => {
|
|
withNode(node())
|
|
render(<Lists list={list} token="t" embedded={embedded} />)
|
|
expect((await screen.findAllByText(sub)).length).toBeGreaterThan(0)
|
|
expect(screen.getAllByText(title).length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Delete weighs what it costs', () => {
|
|
/*
|
|
The fourth problem, and the design brief had it backwards: it said `Delete` and `Flush` were
|
|
the same red pill. They were not: `Flush` already carried `variant="danger"`
|
|
and `Delete` carried NO variant at all, so the destructive verb in the bar was
|
|
drawn as a plain grey button while the one beside it was a filled red block.
|
|
*/
|
|
it('the node Delete is a danger button, not a plain one', async () => {
|
|
withNode(node({ domain: 'ads.example.net', records: [NS] }))
|
|
render(<Lists list="allowed" token="t" embedded />)
|
|
const button = await screen.findByRole('button', { name: 'Delete' })
|
|
expect(button).toHaveAttribute('data-variant', 'danger')
|
|
})
|
|
|
|
/* The header's Flush of Allowed and Blocked left with their header (it is
|
|
Rules' now); the filled red one that stays in this component is Cache's. */
|
|
it('and Flush Cache stays the filled one, in the header', async () => {
|
|
withNode(node())
|
|
render(<Cache token="t" />)
|
|
const flush = await screen.findByRole('button', { name: 'Flush Cache' })
|
|
expect(flush).toHaveAttribute('data-variant', 'danger')
|
|
})
|
|
})
|
|
|
|
describe('loading is not emptiness, on either side', () => {
|
|
function pending() {
|
|
return new Promise<never>(() => {})
|
|
}
|
|
|
|
/*
|
|
The tree had this fixed on 2026-09-07 and the records side did not: it kept
|
|
saying `0 records at <ROOT>` with the request still in flight, which is exactly
|
|
what a node with no records says. Same lie, other column.
|
|
*/
|
|
it('neither count bar is drawn while the request is in flight', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never)
|
|
render(<Lists list="cache" token="t" />)
|
|
await screen.findByRole('status')
|
|
expect(screen.queryByText(/records at/)).not.toBeInTheDocument()
|
|
expect(screen.queryByText('0 zones')).not.toBeInTheDocument()
|
|
})
|
|
|
|
/*
|
|
Two holes, one wait. The eye needs a placeholder in each (that is what says the
|
|
content is coming and not gone), and the ear needs one: two `role="status"` for
|
|
the same request read it out twice.
|
|
*/
|
|
it('both holes show a placeholder and only one of them announces it', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never)
|
|
render(<Lists list="blocked" token="t" embedded />)
|
|
await screen.findByRole('status')
|
|
expect(screen.getAllByText('Loading…')).toHaveLength(2)
|
|
expect(screen.getAllByRole('status')).toHaveLength(1)
|
|
})
|
|
|
|
/* Cache is one panel since 2026-10-16: one hole, one placeholder, announced. */
|
|
it('Cache has one hole, and it announces its wait', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never)
|
|
render(<Cache token="t" />)
|
|
await screen.findByRole('status')
|
|
expect(screen.getAllByRole('status')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('what the round was not allowed to break', () => {
|
|
it('the two empty texts are still two, and each in its own case', async () => {
|
|
withNode(node({ domain: 'example.org', zones: ['a.example.org', 'b.example.org'] }))
|
|
render(<Lists list="blocked" token="t" embedded />)
|
|
expect(
|
|
await screen.findByText('This node only contains sub-domains. Open one in the tree to see its records.'),
|
|
).toBeInTheDocument()
|
|
})
|
|
|
|
it('the field is Domain, the button is Browse, and the placeholder is example.com', async () => {
|
|
withNode(node())
|
|
render(<Cache token="t" />)
|
|
expect(await screen.findByLabelText('Domain')).toHaveAttribute('placeholder', 'example.com')
|
|
expect(screen.getByRole('button', { name: 'Browse' })).toBeInTheDocument()
|
|
})
|
|
|
|
/* `<ROOT>` is how you get back to the root: it is a tree node, not decoration,
|
|
and the path beside it must not have replaced it. */
|
|
it('the tree keeps its <ROOT> node', async () => {
|
|
withNode(node({ domain: 'example.org', zones: [] }))
|
|
render(<Lists list="blocked" token="t" embedded />)
|
|
const tree = await screen.findByLabelText('Domain tree')
|
|
expect(within(tree).getByText('<ROOT>')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('loading the tree', () => {
|
|
/*
|
|
Every successful load used to re-create `load`, and the mount effect ran it
|
|
again from the root: hundreds of `cache/list?domain=` a second, and a tree that
|
|
snapped back to the root whenever a node was opened. The mocks above answer the
|
|
last node to every call, which is why no test saw it; this one answers by
|
|
domain, as the server does.
|
|
*/
|
|
function byDomain() {
|
|
return vi.spyOn(api, 'listNode').mockImplementation(async (_list, _token, domain) => ({
|
|
kind: 'ok' as const,
|
|
data: domain === 'com' ? node({ domain: 'com', zones: ['example'] }) : node({ zones: ['com', 'net'] }),
|
|
}))
|
|
}
|
|
|
|
it('asks for the root once on opening, and not again', async () => {
|
|
const spy = byDomain()
|
|
render(<Cache token="t" />)
|
|
await screen.findByRole('button', { name: 'com' })
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
expect(spy.mock.calls.filter((c) => c[2] === '')).toHaveLength(1)
|
|
})
|
|
|
|
it('opening a node stays on that node', async () => {
|
|
const spy = byDomain()
|
|
render(<Cache token="t" />)
|
|
;(await screen.findByRole('button', { name: 'com' })).click()
|
|
await screen.findByRole('button', { name: 'example' })
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
expect(screen.getByText('Node').parentElement).toHaveTextContent('com')
|
|
expect(spy.mock.calls.filter((c) => c[2] === '')).toHaveLength(1)
|
|
})
|
|
})
|