Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/stovepipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ projects; `?projects=<opaque-token>` 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
Expand Down
57 changes: 51 additions & 6 deletions web/stovepipe/view/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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(<StovepipeApp model={model} />);
Expand All @@ -125,10 +130,12 @@ it("renders recorded project results, completion and shareable pagination", () =
render(<StovepipeApp model={model} />);
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",
Expand Down Expand Up @@ -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(<StovepipeApp model={{
...model,
projectStatus: { ok: true, data: {
...model.projectStatus.data, repositoryBreakageDegree: degree,
} },
}} />);
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(<StovepipeApp model={{
...model, history: { ok: false, error: "History unavailable" },
}} />);
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(<StovepipeApp model={{
...model, projectStatus: { ok: false, error: "Projects unavailable" },
}} />);
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();
});
53 changes: 30 additions & 23 deletions web/stovepipe/view/app.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"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";
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 (
Expand Down Expand Up @@ -59,31 +59,38 @@ function Page({ model }: { model: StovepipePageModel }) {
)}
{model.kind === "queue" ? (
<Section result={model.requests}>
{(data) => <RequestList {...data} />}
{(data) => data && <RequestList {...data} />}
</Section>
) : (
<Block display="grid" gridTemplateColumns="minmax(0, 1fr)" gridGap="scale600">
<Section result={model.history}>
{({ request, events }) => (
<>
<section aria-label="Request status">
<HeadingSmall as="h2" marginTop="0">Request status</HeadingSmall>
<RequestDetails request={request} />
</section>
<section aria-label="History">
<HeadingSmall as="h2" marginTop="0">History</HeadingSmall>
<History events={events} />
</section>
</>
)}
</Section>
<section aria-label="Project results">
<HeadingSmall as="h2" marginTop="0">Project results</HeadingSmall>
<Section result={model.history}>
{(history) => (
<Section result={model.projectStatus}>
{(data) => <ProjectResults model={data} />}
{(projects) => (
<Block display="grid" gridTemplateColumns="minmax(0, 1fr)" gridGap="scale600">
<section aria-label="Request status">
<HeadingSmall as="h2" marginTop="0">Request status</HeadingSmall>
{history && <RequestDetails request={history.request} />}
{projects && (
<Block as="dl" margin="0">
<Fact label="Repository result">
<RecordedResult degree={projects.repositoryBreakageDegree} />
</Fact>
</Block>
)}
</section>
<section aria-label="History">
<HeadingSmall as="h2" marginTop="0">History</HeadingSmall>
{history && <History events={history.events} />}
</section>
<section aria-label="Project results">
<HeadingSmall as="h2" marginTop="0">Project results</HeadingSmall>
{projects && <ProjectResults model={projects} />}
</section>
</Block>
)}
</Section>
</section>
</Block>
)}
</Section>
)}
</Block>
<Block as="footer" paddingTop="scale300" paddingBottom="scale300" font="ParagraphXSmall" color="contentSecondary">
Expand Down
4 changes: 2 additions & 2 deletions web/stovepipe/view/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function Section<T>({
children,
}: {
result: ReadResult<T>;
children(data: T): ReactNode;
children(data: T | null): ReactNode;
}) {
const [previous, setPrevious] = useState<T | null>(
result.ok ? result.data : null,
Expand All @@ -33,7 +33,7 @@ export function Section<T>({
{previous !== null && <ParagraphSmall>Showing the last successful result.</ParagraphSmall>}
</Notification>
)}
{data !== null ? children(data) : null}
{children(data)}
</>
);
}
19 changes: 4 additions & 15 deletions web/stovepipe/view/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,24 @@ 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 (
<Tag closeable={false} size="small" kind={
degree === null ? "neutral" : degree === 0 ? "positive" : "negative"
}>
{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"}
</Tag>
);
}

export function ProjectResults({ model }: { model: ProjectStatusModel }) {
return (
<>
<ParagraphSmall color="contentSecondary">
Breakage ranges from 0% (green) to 100%; missing results are not green.
</ParagraphSmall>
<Table label="Recorded project results" headers={["Scope", "Recorded result"]}>
<StyledTableBodyRow>
<StyledTableBodyCell $as="th" scope="row">Repository</StyledTableBodyCell>
<StyledTableBodyCell><Result degree={model.repositoryBreakageDegree} /></StyledTableBodyCell>
</StyledTableBodyRow>
<Table label="Recorded project results" headers={["Project", "Recorded result"]}>
{model.projects.map((entry) => (
<StyledTableBodyRow key={entry.project}>
<StyledTableBodyCell>{entry.project}</StyledTableBodyCell>
<StyledTableBodyCell><Result degree={entry.breakageDegree} /></StyledTableBodyCell>
<StyledTableBodyCell><RecordedResult degree={entry.breakageDegree} /></StyledTableBodyCell>
</StyledTableBodyRow>
))}
</Table>
Expand Down
44 changes: 24 additions & 20 deletions web/test/e2e/stovepipe/stovepipe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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([]);
});
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion web/test/package/react18/react18-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading