flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
501 lines
20 KiB
TypeScript
501 lines
20 KiB
TypeScript
/**
|
|
* Overview through the real router: Pi-hole's layout over our data.
|
|
*
|
|
* The behaviours of the superseded three-section build are accounted for here or
|
|
* declared dead. The status rows and the issues list moved to the Diagnostics
|
|
* page's health strip and its Active section; the Pause control moved to the
|
|
* sidebar; the protection indicator is gone. What stays here is the period, the
|
|
* window and the panels.
|
|
*/
|
|
|
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
|
import { AuthProvider } from "@/auth/store";
|
|
import { createQueryClient } from "@/lib/queryClient";
|
|
import { createAppRouter } from "@/routes";
|
|
import { clientSeriesColor, typeRampColor } from "./seriesColors";
|
|
import { health } from "@/lib/healthFixture";
|
|
import type { Health, Overview } from "@/lib/types";
|
|
|
|
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
|
|
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
|
|
const COVERAGE = { complete: true, available_since: SINCE };
|
|
|
|
const OVERVIEW: Overview = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
bucket_seconds: 1800,
|
|
totals: { queries: 1000, blocked: 250, clients: 7, avg_response_time_us: 2345 },
|
|
buckets: [
|
|
{ ts: SINCE, queries: 60, blocked: 20, cached: 10 },
|
|
{ ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 },
|
|
],
|
|
clients: [
|
|
{ client: "192.0.2.30", buckets: [40, 20] },
|
|
{ client: "192.0.2.31", buckets: [20, 20] },
|
|
],
|
|
other: [0, 0],
|
|
types: [
|
|
{ qtype: 1, count: 600 },
|
|
{ qtype: 28, count: 300 },
|
|
{ qtype: null, count: 100 },
|
|
],
|
|
routes: [
|
|
{ route: "upstream", source: "https://dns.example/dns-query", count: 500 },
|
|
{ route: "blocked", source: null, count: 250 },
|
|
{ route: "cache", source: null, count: 150 },
|
|
{ route: "upstream", source: null, count: 100 },
|
|
],
|
|
coverage: COVERAGE,
|
|
};
|
|
|
|
/** The same shape an hour wide and empty, so a period change is observable. */
|
|
const HOUR: Overview = {
|
|
...OVERVIEW,
|
|
period: "1h",
|
|
since: UNTIL - 3600,
|
|
bucket_seconds: 60,
|
|
totals: { queries: 12, blocked: 3, clients: 2, avg_response_time_us: 2345 },
|
|
buckets: [],
|
|
clients: [],
|
|
other: [],
|
|
types: [],
|
|
routes: [],
|
|
};
|
|
|
|
let healthBody: Health;
|
|
let failing: boolean;
|
|
/** The registered clients, as `/api/clients` answers them. */
|
|
let registered: { ip: string; name: string; learned_name: string }[];
|
|
let coverageComplete: boolean;
|
|
/** Held in flight, so a test can look at the page while the request is pending. */
|
|
let delayed: Promise<void> | null;
|
|
|
|
function json(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
|
|
return { ...body, coverage: { ...body.coverage, complete: coverageComplete } };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
healthBody = health();
|
|
failing = false;
|
|
registered = [];
|
|
coverageComplete = true;
|
|
delayed = null;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.startsWith("/api/overview")) {
|
|
if (failing) return json({ error: "endpoint unavailable" }, 400);
|
|
if (delayed !== null) await delayed;
|
|
return json(withCoverage(url.includes("period=1h") ? HOUR : OVERVIEW));
|
|
}
|
|
if (url === "/api/clients") {
|
|
return json({
|
|
clients: registered.map((client, index) => ({
|
|
id: index + 1,
|
|
ip: client.ip,
|
|
name: client.name,
|
|
learned_name: client.learned_name,
|
|
group_id: 1,
|
|
group: "default",
|
|
hand_edited: client.name !== "",
|
|
first_seen: SINCE,
|
|
last_seen: UNTIL,
|
|
})),
|
|
});
|
|
}
|
|
if (url === "/api/health") return json(healthBody);
|
|
if (url === "/api/version")
|
|
return json({ version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 });
|
|
if (url.startsWith("/api/diagnostics")) {
|
|
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
|
|
}
|
|
return json({ error: "not stubbed" }, 404);
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
function renderApp(path = "/overview") {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
return router;
|
|
}
|
|
|
|
function panel(name: string): HTMLElement {
|
|
const heading = screen.getByRole("heading", { name });
|
|
const section = heading.closest("section");
|
|
if (section === null) throw new Error(`no panel for ${name}`);
|
|
return section;
|
|
}
|
|
|
|
/** The toolbar's two selectors, named by their aria-label (RAC folds the value into the name too). */
|
|
function periodTrigger(): HTMLElement {
|
|
return screen.getByRole("button", { name: /Period/ });
|
|
}
|
|
|
|
function deviceTrigger(): HTMLElement {
|
|
return screen.getByRole("button", { name: /Device/ });
|
|
}
|
|
|
|
/** RAC opens a Select from the keyboard as readily as from a pointer. */
|
|
function open(trigger: HTMLElement) {
|
|
fireEvent.keyDown(trigger, { key: "Enter" });
|
|
fireEvent.keyUp(trigger, { key: "Enter" });
|
|
}
|
|
|
|
/** Whether any request so far carried this query string fragment. */
|
|
function requested(fragment: string): boolean {
|
|
return vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes(fragment));
|
|
}
|
|
|
|
test("the root path lands on Overview rather than aliasing it", async () => {
|
|
const router = renderApp("/");
|
|
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
|
expect(router.state.location.pathname).toBe("/overview");
|
|
});
|
|
|
|
test("every donut arc is outlined, so two slices of one hue still read as two", async () => {
|
|
// Colour is a pure function of identity and so cannot rule out two slices of
|
|
// one panel sharing a hue. The stroke is what stops neighbours from merging
|
|
// into one shape, which makes it part of the contract rather than decoration.
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
|
|
|
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
|
|
expect(arcs).toHaveLength(3);
|
|
for (const arc of arcs) {
|
|
expect(arc.getAttribute("stroke-width")).toBe("1");
|
|
// The panel's own surface colour, as a token reference.
|
|
expect(arc.getAttribute("stroke")).toMatch(/^var\(--/);
|
|
}
|
|
});
|
|
|
|
test("the types ring steps the accent's ramp from the busiest type outward", async () => {
|
|
// `Donut` renders the colour it is handed and never recomputes one, so the
|
|
// mapping from rank to ramp step is the page's job and is pinned here.
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
|
|
|
const swatchOf = (label: string) => {
|
|
const item = within(panel("Query types")).getAllByText(label)[0].closest("li") as HTMLElement;
|
|
return (item.querySelector("span[aria-hidden]") as HTMLElement).getAttribute("style");
|
|
};
|
|
expect(swatchOf("A")).toContain(typeRampColor(0));
|
|
expect(swatchOf("AAAA")).toContain(typeRampColor(1));
|
|
expect(swatchOf("Unknown")).toContain(typeRampColor(2));
|
|
});
|
|
|
|
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
|
|
// Through the real route, which is the point: the loader starts the request
|
|
// and awaits it nowhere. If it awaited, the router would hold the whole page —
|
|
// heading and period picker included — until the response landed.
|
|
let release = () => {};
|
|
delayed = new Promise<void>((resolve) => (release = resolve));
|
|
|
|
renderApp();
|
|
|
|
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
|
expect(periodTrigger().textContent).toContain("Last 24 hours");
|
|
// One loading state for the whole page, not one per panel.
|
|
const loading = await screen.findByText("Loading…");
|
|
expect(loading.getAttribute("role")).toBe("status");
|
|
expect(screen.getAllByText("Loading…")).toHaveLength(1);
|
|
expect(screen.queryByRole("heading", { name: "Query types" })).toBeNull();
|
|
|
|
release();
|
|
delayed = null;
|
|
await screen.findAllByText("1,000");
|
|
expect(screen.queryByText("Loading…")).toBeNull();
|
|
});
|
|
|
|
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
|
|
// The fixture's two clients: one registered with a typed name, one the clients
|
|
// list has never seen.
|
|
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
|
renderApp();
|
|
const chart = await waitFor(() => panel("Client activity over time"));
|
|
|
|
// Legend and the hidden table both, since the table is what a screen reader
|
|
// gets instead of the graphic and the two must not name one client differently.
|
|
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
|
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
|
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
|
|
|
// Nor on hover: a pointer-only tooltip would say what the design just chose
|
|
// not to, and only to a reader holding a mouse.
|
|
const legendItem = within(chart)
|
|
.getAllByText("kitchen-pi")
|
|
.map((node) => node.closest("li"))
|
|
.find((node) => node !== null);
|
|
expect(legendItem).toBeTruthy();
|
|
expect(legendItem?.getAttribute("title")).toBeNull();
|
|
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
|
|
});
|
|
|
|
test("a client named only by reverse DNS is named by it too", async () => {
|
|
registered = [{ ip: "192.0.2.31", name: "", learned_name: "laptop.lan" }];
|
|
renderApp();
|
|
const chart = await waitFor(() => panel("Client activity over time"));
|
|
|
|
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
|
|
});
|
|
|
|
test("naming a client does not recolour its series: colour goes by rank", async () => {
|
|
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
|
|
// the busiest client's hue, whatever the label on screen says.
|
|
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
|
renderApp();
|
|
const chart = await waitFor(() => panel("Client activity over time"));
|
|
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
|
|
|
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
|
|
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
|
expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
|
|
});
|
|
|
|
test("the client chart keeps Other in a period where it counted nothing", async () => {
|
|
// The fixture's other series is all zeroes. Other is still a series, so the
|
|
// legend reads the same in every scope and its zero says the named clients
|
|
// were the whole story.
|
|
renderApp();
|
|
await screen.findByRole("heading", { name: "Client activity over time" });
|
|
|
|
const chart = screen.getByRole("heading", { name: "Client activity over time" }).closest("section");
|
|
expect(chart).toBeTruthy();
|
|
// Twice each: the legend swatch and the column header of the table a screen
|
|
// reader gets instead of the graphic.
|
|
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
|
|
expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2);
|
|
});
|
|
|
|
test("the page is four tiles, two charts, the cache card and two donuts — no status or issues sections", async () => {
|
|
renderApp();
|
|
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
|
await screen.findAllByText("1,000");
|
|
|
|
for (const name of [
|
|
"Queries over time",
|
|
"Client activity over time",
|
|
"Cache hit rate",
|
|
"Query types",
|
|
"Upstream servers",
|
|
]) {
|
|
expect(screen.getByRole("heading", { name })).toBeTruthy();
|
|
}
|
|
// Every card leads with its title and a one-line description under it.
|
|
for (const section of screen.getAllByRole("region")) {
|
|
expect(section.querySelector("h2 + p")?.textContent).toBeTruthy();
|
|
}
|
|
// The sections the layout ruling removed, and the widgets the Dashboard lost.
|
|
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
|
|
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
|
|
expect(screen.queryByRole("heading", { name: "Activity over a period" })).toBeNull();
|
|
expect(screen.queryByText("Storage now")).toBeNull();
|
|
expect(screen.queryByRole("columnheader", { name: "Upstream" })).toBeNull();
|
|
});
|
|
|
|
test("the four tiles report the window, and each links where its number leads", async () => {
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
const tiles = within(screen.getByRole("list", { name: "Totals" }));
|
|
expect(tiles.getByText("1,000")).toBeTruthy();
|
|
expect(tiles.getByText("250")).toBeTruthy();
|
|
expect(tiles.getByText("25.00%")).toBeTruthy();
|
|
expect(tiles.getByText("7")).toBeTruthy();
|
|
expect(tiles.getAllByRole("listitem").map((item) => item.querySelector("p + p")?.textContent)).toEqual([
|
|
"queries",
|
|
"blocked queries",
|
|
"active clients",
|
|
"of queries blocked",
|
|
]);
|
|
|
|
// The bounds are the ones the stats response returned, not ones computed here.
|
|
const queries = new URLSearchParams(
|
|
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
|
|
);
|
|
expect(queries.get("mode")).toBe("history");
|
|
expect(queries.get("since")).toBe(String(SINCE));
|
|
expect(queries.get("until")).toBe(String(UNTIL));
|
|
expect(queries.get("blocked")).toBeNull();
|
|
|
|
const blocked = new URLSearchParams(
|
|
screen.getByRole("link", { name: "Open blocked queries" }).getAttribute("href")?.split("?")[1] ?? "",
|
|
);
|
|
expect(blocked.get("blocked")).toBe("true");
|
|
expect(blocked.get("since")).toBe(String(SINCE));
|
|
|
|
expect(screen.getByRole("link", { name: "Manage clients" }).getAttribute("href")).toBe("/clients");
|
|
// Average response time has no rows behind it to open.
|
|
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
|
|
});
|
|
|
|
test("the cache card reads the hit share off the buckets and the forwarded count off the routes", async () => {
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
|
|
const cache = within(panel("Cache hit rate"));
|
|
// 10 cached answers of 1,000 queries; 500 + 100 went to an upstream.
|
|
expect(cache.getByText("1.00%")).toBeTruthy();
|
|
expect(cache.getByRole("img", { name: "1.00% of queries served from cache" })).toBeTruthy();
|
|
expect(cache.getByText("10")).toBeTruthy();
|
|
expect(cache.getByText("600")).toBeTruthy();
|
|
expect(cache.getByText("2.3 ms")).toBeTruthy();
|
|
expect(cache.getByText("of queries answered from cache")).toBeTruthy();
|
|
});
|
|
|
|
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
|
|
const types = within(panel("Query types"));
|
|
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
|
|
expect(types.getByRole("rowheader", { name: "AAAA" })).toBeTruthy();
|
|
// A query whose type was never recorded is its own entry, not a dropped row.
|
|
expect(types.getByRole("rowheader", { name: "Unknown" })).toBeTruthy();
|
|
|
|
const routes = within(panel("Upstream servers"));
|
|
expect(routes.getByRole("rowheader", { name: "https://dns.example/dns-query (Upstream)" })).toBeTruthy();
|
|
expect(routes.getByRole("rowheader", { name: "Blocked" })).toBeTruthy();
|
|
expect(routes.getByRole("rowheader", { name: "Cache" })).toBeTruthy();
|
|
// An upstream row with no recorded resolver reads as Unknown, qualified by its kind.
|
|
expect(routes.getByRole("rowheader", { name: "Unknown (Upstream)" })).toBeTruthy();
|
|
});
|
|
|
|
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
|
|
const svg = panel("Query types").querySelector("svg");
|
|
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
|
expect(svg?.getAttribute("focusable")).toBe("false");
|
|
expect(within(panel("Query types")).getByRole("table")).toBeTruthy();
|
|
});
|
|
|
|
test("an empty window says so in every panel instead of drawing nothing", async () => {
|
|
renderApp("/overview?period=1h");
|
|
await screen.findAllByText("12");
|
|
|
|
// The two donuts and the client chart; the query-volume chart says it too.
|
|
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
|
|
});
|
|
|
|
test("a deep link opens on the period it names, and the picker offers the four in words", async () => {
|
|
renderApp("/overview?period=1h");
|
|
await screen.findAllByText("12");
|
|
expect(periodTrigger().textContent).toContain("Last hour");
|
|
|
|
open(periodTrigger());
|
|
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
|
|
"Last hour",
|
|
"Last 24 hours",
|
|
"Last 7 days",
|
|
"Last 30 days",
|
|
]);
|
|
});
|
|
|
|
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
|
const router = renderApp("/overview?period=90d");
|
|
await screen.findAllByText("1,000");
|
|
expect(periodTrigger().textContent).toContain("Last 24 hours");
|
|
expect(router.state.location.search).toEqual({});
|
|
});
|
|
|
|
test("the picker rescopes every panel and writes the period into the url", async () => {
|
|
const router = renderApp();
|
|
await screen.findAllByText("1,000");
|
|
|
|
open(periodTrigger());
|
|
fireEvent.click(screen.getByRole("option", { name: "Last hour" }));
|
|
|
|
await screen.findAllByText("12");
|
|
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
|
// No panel is left describing the period the reader left.
|
|
expect(screen.queryByText("1,000")).toBeNull();
|
|
});
|
|
|
|
test("a deep link to one device scopes the request, the tiles' links and the picker", async () => {
|
|
renderApp("/overview?client=192.0.2.31");
|
|
await screen.findAllByText("1,000");
|
|
|
|
expect(requested("client=192.0.2.31")).toBe(true);
|
|
// Not registered, so the picker shows the address rather than claiming the household.
|
|
expect(deviceTrigger().textContent).toContain("192.0.2.31");
|
|
const queries = new URLSearchParams(
|
|
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
|
|
);
|
|
expect(queries.get("client")).toBe("192.0.2.31");
|
|
});
|
|
|
|
test("the device picker names the registered clients and writes the choice into the url", async () => {
|
|
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
|
const router = renderApp();
|
|
await screen.findAllByText("1,000");
|
|
expect(deviceTrigger().textContent).toContain("All devices");
|
|
|
|
// The clients list has landed once the chart names the client by it.
|
|
await waitFor(() => expect(within(panel("Client activity over time")).getAllByText("kitchen-pi")).toHaveLength(2));
|
|
open(deviceTrigger());
|
|
fireEvent.click(screen.getByRole("option", { name: "kitchen-pi" }));
|
|
|
|
await waitFor(() => expect(router.state.location.search).toEqual({ client: "192.0.2.30" }));
|
|
await waitFor(() => expect(requested("client=192.0.2.30")).toBe(true));
|
|
expect(deviceTrigger().textContent).toContain("kitchen-pi");
|
|
|
|
// Back to the household drops the parameter rather than writing an empty one.
|
|
open(deviceTrigger());
|
|
fireEvent.click(screen.getByRole("option", { name: "All devices" }));
|
|
await waitFor(() => expect(router.state.location.search).toEqual({}));
|
|
});
|
|
|
|
test("a client list in the url is not this page's grammar and is dropped", async () => {
|
|
const router = renderApp("/overview?client=192.0.2.30,192.0.2.31");
|
|
await screen.findAllByText("1,000");
|
|
expect(router.state.location.search).toEqual({});
|
|
expect(requested("client=")).toBe(false);
|
|
});
|
|
|
|
test("a failed request is one error for the whole page, stated once and retryable", async () => {
|
|
failing = true;
|
|
renderApp();
|
|
|
|
await screen.findByText("endpoint unavailable");
|
|
// One statement of the failure, not one per panel: there is a single request
|
|
// behind every panel, so a second copy would only repeat this sentence.
|
|
expect(screen.getAllByText("endpoint unavailable")).toHaveLength(1);
|
|
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
|
// The heading and the picker survive it, so the reader can rescope or retry.
|
|
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
|
expect(periodTrigger()).toBeTruthy();
|
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
|
|
|
failing = false;
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
await screen.findAllByText("1,000");
|
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
|
});
|
|
|
|
test("an incomplete window states its watermark once for the whole page", async () => {
|
|
coverageComplete = false;
|
|
renderApp();
|
|
await screen.findAllByText("1,000");
|
|
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
|
|
});
|