diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 1fcf96ecb..654c08dc1 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -20,6 +20,8 @@ projects; `?projects=` carries its continuation and can be shared. First-page navigation removes the cursor. Pages are live rather than a snapshot; return to the first page to see newly recorded projects. +The repository result appears in the request summary above history; the project results table contains only projects. Recorded results show Green or Breakage without percentages. + Repository breakage, per-project breakage, and recording completion are separate. Zero breakage is green; absent results are never treated as green. Completion describes result production, not whether all pages have been displayed. Project diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 2ba81ff23..bae9e3c9e 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -62,8 +62,11 @@ it("displays the backend summary above the retained history", () => { expect(within(status).getByText("all_targets_passed")).toBeVisible(); expect(within(status).getByText("Accepted")).toBeVisible(); expect(within(status).getByText("Updated")).toBeVisible(); + expect(within(status).getByText("Repository result")).toBeVisible(); + expect(within(status).getByText("No result recorded")).toBeVisible(); const history = screen.getByRole("region", { name: "History" }); expect(within(history).getByText("accepted")).toBeVisible(); + expect(status.compareDocumentPosition(history) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect( screen.getAllByRole("heading", { level: 2 }).map((heading) => heading.textContent), ).toEqual(["Request status", "History", "Project results"]); @@ -103,6 +106,8 @@ it("retains the summary and history after a failed refresh", async () => { expect(screen.getByText("accepted")).toBeVisible(); await waitFor(() => expect(screen.getAllByText("Showing the last successful result.")).toHaveLength(2)); expect(screen.getByText("green-project")).toBeVisible(); + expect(within(screen.getByRole("region", { name: "Request status" })) + .getByText("No result recorded")).toBeVisible(); }); it("discards previous results when the request changes", () => { const { rerender } = render(); @@ -125,10 +130,12 @@ it("renders recorded project results, completion and shareable pagination", () = render(); const projects = screen.getByRole("region", { name: "Project results" }); const table = within(projects).getByRole("table", { name: "Recorded project results" }); - expect(within(table).getByRole("rowheader", { name: "Repository" })).toBeVisible(); - expect(within(projects).getByText("Green (0%)")).toBeVisible(); - expect(within(projects).getByText("Breakage (25%)")).toBeVisible(); - expect(within(projects).getAllByText("No result recorded")).toHaveLength(2); + expect(within(table).queryByText(/Repository/)).toBeNull(); + expect(within(table).getByRole("columnheader", { name: "Project" })).toBeVisible(); + expect(within(projects).getByText("Green")).toBeVisible(); + expect(within(projects).getByText("Breakage")).toBeVisible(); + expect(within(projects).getByText("No result recorded")).toBeVisible(); + expect(within(projects).queryByText(/%/)).toBeNull(); expect(within(projects).getByText(/still in progress/)).toBeVisible(); expect(within(projects).getByRole("link", { name: /Next projects/ })).toHaveAttribute( "href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D", @@ -161,6 +168,44 @@ it("shows complete empty results without inventing green or a continuation", () expect(screen.getByText("No project results recorded on this page.")).toBeVisible(); expect(screen.queryByRole("link", { name: /Next projects/ })).toBeNull(); const table = screen.getByRole("table", { name: "Recorded project results" }); - expect(within(table).getByRole("rowheader", { name: "Repository" })).toBeVisible(); - expect(within(table).getByText("No result recorded")).toBeVisible(); + expect(within(table).getAllByRole("row")).toHaveLength(1); + expect(within(screen.getByRole("region", { name: "Request status" })) + .getByText("No result recorded")).toBeVisible(); +}); + +it.each([ + [0, "Green"], + [0.25, "Breakage"], + [null, "No result recorded"], +] as const)("shows repository result %s above history as %s", (degree, label) => { + if (!model.projectStatus.ok) throw new Error("No projects"); + render(); + const status = screen.getByRole("region", { name: "Request status" }); + expect(within(status).getByText(label)).toBeVisible(); + expect(within(status).queryByText(/%/)).toBeNull(); +}); + +it("shows repository and project results when history is unavailable", async () => { + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("History unavailable"); + expect(within(screen.getByRole("region", { name: "Request status" })) + .getByText("No result recorded")).toBeVisible(); + expect(screen.getByText("green-project")).toBeVisible(); +}); + +it("shows history when repository and project results are unavailable", async () => { + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("Projects unavailable"); + expect(screen.getByText("accepted")).toBeVisible(); + expect(screen.getByText("git://repo/sha")).toBeVisible(); + expect(screen.queryByText("Repository result")).toBeNull(); }); diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index ca57a6a69..28fc98d35 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -1,6 +1,6 @@ "use client"; -import { Timestamp } from "@submitqueue/web-platform"; +import { Fact, Timestamp } from "@submitqueue/web-platform"; import { Block } from "baseui/block/index.js"; import { Tag } from "baseui/tag/index.js"; import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography/index.js"; @@ -8,7 +8,7 @@ import { RefreshControls } from "./refresh.js"; import type { StovepipePageModel } from "../entity/index.js"; import { Link, Section } from "./components.js"; import { RequestList, RequestDetails, History } from "./pages.js"; -import { ProjectResults } from "./projects.js"; +import { ProjectResults, RecordedResult } from "./projects.js"; function Page({ model }: { model: StovepipePageModel }) { return ( @@ -59,31 +59,38 @@ function Page({ model }: { model: StovepipePageModel }) { )} {model.kind === "queue" ? (
- {(data) => } + {(data) => data && }
) : ( - -
- {({ request, events }) => ( - <> -
- Request status - -
-
- History - -
- - )} -
-
- Project results +
+ {(history) => (
- {(data) => } + {(projects) => ( + +
+ Request status + {history && } + {projects && ( + + + + + + )} +
+
+ History + {history && } +
+
+ Project results + {projects && } +
+
+ )}
-
- + )} +
)}
diff --git a/web/stovepipe/view/components.tsx b/web/stovepipe/view/components.tsx index c90b5456d..0736b1cc8 100644 --- a/web/stovepipe/view/components.tsx +++ b/web/stovepipe/view/components.tsx @@ -18,7 +18,7 @@ export function Section({ children, }: { result: ReadResult; - children(data: T): ReactNode; + children(data: T | null): ReactNode; }) { const [previous, setPrevious] = useState( result.ok ? result.data : null, @@ -33,7 +33,7 @@ export function Section({ {previous !== null && Showing the last successful result.} )} - {data !== null ? children(data) : null} + {children(data)} ); } diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index 639c9e96f..2ca58594c 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -8,16 +8,12 @@ import { ParagraphSmall } from "baseui/typography/index.js"; import type { ProjectStatusModel } from "../entity/index.js"; import { Link, Table } from "./components.js"; -function Result({ degree }: { degree: number | null }) { +export function RecordedResult({ degree }: { degree: number | null }) { return ( - {degree === null ? "No result recorded" : ( - <>{degree === 0 ? "Green" : "Breakage"} ({ - (degree * 100).toLocaleString("en-US", { maximumSignificantDigits: 6 }) - }%) - )} + {degree === null ? "No result recorded" : degree === 0 ? "Green" : "Breakage"} ); } @@ -25,18 +21,11 @@ function Result({ degree }: { degree: number | null }) { export function ProjectResults({ model }: { model: ProjectStatusModel }) { return ( <> - - Breakage ranges from 0% (green) to 100%; missing results are not green. - - - - Repository - - +
{model.projects.map((entry) => ( {entry.project} - + ))}
diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index 03ffcb714..009927d90 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -20,8 +20,11 @@ test.describe("server-rendered styling", () => { }); test("separates request status fields before hydration", async ({ page }) => { await page.goto("/web%2Fpagination/request/51"); - const rows = page.getByRole("region", { name: "Request status" }).locator("dl > div"); - await expect(rows).toHaveCount(6); + const status = page.getByRole("region", { name: "Request status" }); + const rows = status.locator("dl > div"); + await expect(rows).toHaveCount(7); + await expect(status.getByText("Repository result", { exact: true })).toBeVisible(); + await expect(status.getByText("Breakage", { exact: true })).toBeVisible(); const layout = await rows.evaluateAll(fields => fields.map(field => ({ height: field.getBoundingClientRect().height, labelGap: field.querySelector("dd")!.getBoundingClientRect().top - field.querySelector("dt")!.getBoundingClientRect().bottom, @@ -157,38 +160,38 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj }); }); await test.step("Read recorded project results and reload their cursor", async () => { + const status = page.getByRole("region", { name: "Request status", exact: true }); const section = page.getByRole("region", { name: "Project results", exact: true }); const projects = section.getByRole("table", { name: "Recorded project results", exact: true }); - await expect(projects.locator("tbody tr")).toHaveCount(51); - await expect( - projects.getByRole("row").filter({ - has: page.getByRole("rowheader", { name: "Repository", exact: true }), - }), - ).toContainText("Breakage (25%)"); + await expect(projects.locator("tbody tr")).toHaveCount(50); + await expect(status.getByText("Repository result", { exact: true })).toBeVisible(); + await expect(status.getByText("Breakage", { exact: true })).toBeVisible(); + await expect(projects.getByText(/Repository|%/)).toHaveCount(0); await expect( projects.getByRole("row").filter({ has: page.getByRole("cell", { name: "project-01", exact: true }), - }), - ).toContainText("Breakage (25%)"); + }).getByRole("cell", { name: "Breakage", exact: true }), + ).toBeVisible(); await expect( projects.getByRole("row").filter({ has: page.getByRole("cell", { name: "project-02", exact: true }), - }), - ).toContainText("Green (0%)"); + }).getByRole("cell", { name: "Green", exact: true }), + ).toBeVisible(); await refresh.click(); await expect(refresh).toBeEnabled(); - await expect(projects.locator("tbody tr")).toHaveCount(51); + await expect(projects.locator("tbody tr")).toHaveCount(50); await section.getByRole("link", { name: "Next projects →", exact: true }).click(); - await expect(projects.locator("tbody tr")).toHaveCount(2); + await expect(projects.locator("tbody tr")).toHaveCount(1); await expect(projects.getByRole("cell", { name: "project-51", exact: true })).toBeVisible(); const cursor = new URL(page.url()).searchParams.get("projects"); expect(cursor).toBeTruthy(); await page.reload(); - await expect(projects.locator("tbody tr")).toHaveCount(2); + await expect(projects.locator("tbody tr")).toHaveCount(1); + await expect(status.getByText("Breakage", { exact: true })).toBeVisible(); expect(new URL(page.url()).searchParams.get("projects")).toBe(cursor); await expect(section.getByRole("link", { name: "Next projects →", exact: true })).toHaveCount(0); await section.getByRole("link", { name: "First projects", exact: true }).click(); - await expect(projects.locator("tbody tr")).toHaveCount(51); + await expect(projects.locator("tbody tr")).toHaveCount(50); expect(new URL(page.url()).searchParams.has("projects")).toBe(false); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); @@ -197,10 +200,11 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj const section = page.getByRole("region", { name: "Project results", exact: true }); await expect(section.getByText("No project results recorded on this page.", { exact: true })).toBeVisible(); const projects = section.getByRole("table", { name: "Recorded project results", exact: true }); - await expect(projects.locator("tbody tr")).toHaveCount(1); - await expect(projects.getByRole("rowheader", { name: "Repository", exact: true })).toBeVisible(); - await expect(projects.getByRole("cell", { name: "No result recorded", exact: true })).toBeVisible(); - await expect(section.getByText("Green (0%)", { exact: true })).toHaveCount(0); + await expect(projects.locator("tbody tr")).toHaveCount(0); + const status = page.getByRole("region", { name: "Request status", exact: true }); + await expect(status.getByText("Repository result", { exact: true })).toBeVisible(); + await expect(status.getByText("No result recorded", { exact: true })).toBeVisible(); + await expect(page.getByText("Green", { exact: true })).toHaveCount(0); }); await test.step("Follow and reload the queue cursor, then select an empty queue", async () => { await page.getByRole("link", { name: "← Queue requests" }).click(); diff --git a/web/test/package/react18/react18-runtime.mjs b/web/test/package/react18/react18-runtime.mjs index 990ff5ae0..27b0413d1 100644 --- a/web/test/package/react18/react18-runtime.mjs +++ b/web/test/package/react18/react18-runtime.mjs @@ -110,7 +110,9 @@ for (const { route, page, theme, html, stylesheets } of pages) { expected.remove(); if (route === "/repo%2Fmain/request/1") { assert.match(container.textContent, /example-project/u); - assert.match(container.textContent, /Breakage \(25%\)/u); + assert.match(container.textContent, /Repository resultBreakage/u); + assert.match(container.textContent, /example-projectGreen/u); + assert.doesNotMatch(container.textContent, /%/u); } await act(async () => root.unmount()); container.remove();