diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md
index 0debd70f4..5ef6efa1a 100644
--- a/web/stovepipe/README.md
+++ b/web/stovepipe/README.md
@@ -29,6 +29,8 @@ return to the first page to see newly recorded projects.
The request page shows a compact summary with change and base URIs always visible. Project results are the default view; History is a separate tab at `?view=history`. Build and Test Issues (`?view=issues`) and Fix Attempts (`?view=fix-attempts`) show Coming soon. Switching tabs preserves the project cursor. Project results and history tables fill the remaining page height and scroll within the active tab. On small screens, a minimum table height keeps rows usable while the page can scroll to reach the controls. Project pagination controls appear above and below the table, show the number of projects on the page, and use the host router without a document reload or resetting the document scroll position. A new project page starts at the top of the table, while the surrounding page and pagination controls stay in place, including on a short final page. Host router links should honor `WebLinkProps.scroll` to support this behavior. Browser Back and Forward restore the selected tab and cursor; links also work without JavaScript. The repository result appears in the summary above all tabs; the project results table contains only projects. Recorded results show Green at zero, Partial Breakage between zero and one, and Breakage at one, without percentages.
+Queue rows and request summaries show recorded broken projects out of the total when the total is positive, including zero broken projects. A zero total displays a dash. Counts come from the backend summary, not the current page of project results, so pagination does not change the ratio.
+
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/controller/mapping.ts b/web/stovepipe/controller/mapping.ts
index c5b996388..dfbdd706b 100644
--- a/web/stovepipe/controller/mapping.ts
+++ b/web/stovepipe/controller/mapping.ts
@@ -15,5 +15,7 @@ export function mapRequestSummary(
updatedAtMs: request.stateUpdatedAtMs.toString(),
acceptedAtMs: request.acceptedAtMs.toString(),
outcomeReason: request.outcomeReason,
+ projectCount: request.projectCount,
+ brokenProjectCount: request.brokenProjectCount,
};
}
diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts
index b33c6e6eb..e2fb71535 100644
--- a/web/stovepipe/entity/index.ts
+++ b/web/stovepipe/entity/index.ts
@@ -22,6 +22,10 @@ export interface RequestSummaryModel {
acceptedAtMs: string;
/** Stable backend outcome reason, empty when absent. */
outcomeReason: string;
+ /** Total recorded project results; zero means no ratio to display. */
+ projectCount: number;
+ /** Recorded project results with nonzero breakage. */
+ brokenProjectCount: number;
}
export interface QueueRequestsModel {
/** Requests on this page, newest accepted first. */
diff --git a/web/stovepipe/extension/service/index.ts b/web/stovepipe/extension/service/index.ts
index 17da4ea2d..207b709f0 100644
--- a/web/stovepipe/extension/service/index.ts
+++ b/web/stovepipe/extension/service/index.ts
@@ -7,6 +7,8 @@ export interface RequestSummary {
stateUpdatedAtMs: bigint;
acceptedAtMs: bigint;
outcomeReason: string;
+ projectCount: number;
+ brokenProjectCount: number;
}
export interface HistoryEvent {
diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts
index 8d950bedf..e739daaa6 100644
--- a/web/stovepipe/module.test.ts
+++ b/web/stovepipe/module.test.ts
@@ -16,6 +16,8 @@ const summary: RequestSummary = {
stateUpdatedAtMs: 9007199254740993n,
acceptedAtMs: 100n,
outcomeReason: "future_reason",
+ projectCount: 51,
+ brokenProjectCount: 2,
};
function createPageTestContext() {
const service: StovepipeService = {
@@ -157,6 +159,10 @@ describe("Stovepipe page loading", () => {
]);
expect(service.getRequestHistoryByID).not.toHaveBeenCalled();
expect(service.getProjectStatusByID).not.toHaveBeenCalled();
+ expect(result.model.requests.data.requests).toEqual([
+ expect.objectContaining({ projectCount: 51, brokenProjectCount: 2 }),
+ expect.objectContaining({ projectCount: 51, brokenProjectCount: 2 }),
+ ]);
});
it("loads the current summary and events by ID without reconstructing state from history", async () => {
const { app, service, paths } = createPageTestContext();
@@ -190,6 +196,8 @@ describe("Stovepipe page loading", () => {
updatedAtMs: "9007199254740993",
acceptedAtMs: "100",
outcomeReason: "future_reason",
+ projectCount: 51,
+ brokenProjectCount: 2,
},
events: [{ label: "future_event", outcomeReason: "event_reason" }],
},
diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx
index 32beb849f..74cb59ba2 100644
--- a/web/stovepipe/view/app.test.tsx
+++ b/web/stovepipe/view/app.test.tsx
@@ -45,6 +45,8 @@ const model: StovepipePageModel = {
updatedAtMs: "1000",
acceptedAtMs: "100",
outcomeReason: "all_targets_passed",
+ projectCount: 51,
+ brokenProjectCount: 2,
},
events: [
{
@@ -65,6 +67,7 @@ it("displays request summary data", () => {
expect(within(status).getByText("git://repo/sha")).toBeVisible();
expect(within(status).getByText("git://repo/base")).toBeVisible();
expect(within(status).getByText("all_targets_passed")).toBeVisible();
+ expect(within(status).getByText("2 of 51")).toBeVisible();
expect(within(status).getByText("Accepted")).toBeVisible();
expect(within(status).getByText("Updated")).toBeVisible();
expect(within(status).getByText("Repository result")).toBeVisible();
@@ -104,6 +107,7 @@ it("retains the summary and history after a failed refresh", async () => {
);
expect(screen.getByText("git://repo/sha")).toBeVisible();
expect(screen.getByText("all_targets_passed")).toBeVisible();
+ expect(screen.getByText("2 of 51")).toBeVisible();
expect(screen.getByText("accepted")).toBeInTheDocument();
await waitFor(() => expect(screen.getAllByText("Showing the last successful result.")).toHaveLength(2));
expect(screen.getByText("green-project")).toBeVisible();
@@ -124,6 +128,7 @@ it("discards previous results when the request changes", () => {
expect(screen.getByRole("heading", { name: "Request 8" })).toBeVisible();
expect(screen.queryByText("git://repo/sha")).toBeNull();
expect(screen.queryByText("all_targets_passed")).toBeNull();
+ expect(screen.queryByText("2 of 51")).toBeNull();
expect(screen.queryByText("green-project")).toBeNull();
});
@@ -138,6 +143,8 @@ it("renders recorded project results, completion and shareable pagination", () =
expect(within(projects).getByText("Breakage")).toBeVisible();
expect(within(projects).getByText("No result recorded")).toBeVisible();
expect(within(projects).queryByText(/%/)).toBeNull();
+ expect(within(screen.getByRole("region", { name: "Request status" }))
+ .getByText("2 of 51")).toBeVisible();
expect(within(projects).getByText(/still in progress/)).toBeVisible();
for (const link of within(projects).getAllByRole("link", { name: /Next projects/ })) {
expect(link).toHaveAttribute("href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D");
diff --git a/web/stovepipe/view/pages.test.tsx b/web/stovepipe/view/pages.test.tsx
new file mode 100644
index 000000000..a818bcb8d
--- /dev/null
+++ b/web/stovepipe/view/pages.test.tsx
@@ -0,0 +1,56 @@
+import { cleanup, render, screen, within } from "../../platform/test/rtl.js";
+import { afterEach, expect, it } from "vitest";
+import type { RequestSummaryModel } from "../entity/index.js";
+import { RequestDetails, RequestList } from "./pages.js";
+import { QueueRequests } from "./queue.js";
+
+afterEach(cleanup);
+
+const request: RequestSummaryModel = {
+ id: "7",
+ href: "/queue/request/7",
+ changeUri: "git://repo/change",
+ baseUri: "",
+ state: "failed",
+ acceptedAtMs: "1000",
+ updatedAtMs: "2000",
+ outcomeReason: "build_failed",
+ projectCount: 0,
+ brokenProjectCount: 0,
+};
+
+it.each([
+ { name: "no recorded projects", total: 0, broken: 0, display: "—" },
+ { name: "all green", total: 51, broken: 0, display: "0 of 51" },
+ { name: "mixed outcomes", total: 51, broken: 2, display: "2 of 51" },
+ { name: "all broken", total: 51, broken: 51, display: "51 of 51" },
+])("shows $name in the queue and request summary", ({ total, broken, display }) => {
+ const summary = { ...request, projectCount: total, brokenProjectCount: broken };
+ render(
+ <>
+
+
+ >,
+ );
+ const table = within(screen.getByRole("table", { name: "Requests" }));
+ expect(table.getByRole("columnheader", { name: "Broken projects" })).toBeVisible();
+ expect(table.getByRole("cell", { name: display })).toBeVisible();
+ const details = within(screen.getByRole("region", { name: "Summary" }));
+ expect(details.getByText("Broken projects")).toBeVisible();
+ expect(details.getByText(display, { exact: true })).toBeVisible();
+});
+
+it("refreshes counts on an already displayed queue row", () => {
+ const { rerender } = render(
+ ,
+ );
+ const table = within(screen.getByRole("table", { name: "Requests" }));
+ expect(table.getByRole("cell", { name: "—" })).toBeVisible();
+ rerender(
+ ,
+ );
+ expect(table.getByRole("cell", { name: "1 of 3" })).toBeVisible();
+});
diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx
index 0026000ae..a420b6104 100644
--- a/web/stovepipe/view/pages.tsx
+++ b/web/stovepipe/view/pages.tsx
@@ -19,6 +19,12 @@ function RequestTimestamp({ value }: { value: string }) {
return ;
}
+function formatBrokenProjectCounts(request: RequestSummaryModel) {
+ return request.projectCount > 0
+ ? `${request.brokenProjectCount} of ${request.projectCount}`
+ : "—";
+}
+
export function RequestList({
requests,
}: {
@@ -37,6 +43,7 @@ export function RequestList({
"Accepted",
"Updated",
"Outcome",
+ "Broken projects",
]}
>
{requests.map((request) => (
@@ -59,6 +66,7 @@ export function RequestList({
{request.outcomeReason || "—"}
+ {formatBrokenProjectCounts(request)}
))}
@@ -68,7 +76,7 @@ export function RequestList({
export function RequestDetails({ request, repositoryResult }: { request: RequestSummaryModel | null; repositoryResult: ReactNode }) {
return (
<>
-
+
{request &&
{request.state || "Unknown"}
@@ -79,6 +87,7 @@ export function RequestDetails({ request, repositoryResult }: { request: Request
{request.outcomeReason || "—"}
+ {formatBrokenProjectCounts(request)}
>}
{request &&
diff --git a/web/test/e2e/stovepipe/run_e2e.py b/web/test/e2e/stovepipe/run_e2e.py
index b7694dba3..54b88fb9d 100644
--- a/web/test/e2e/stovepipe/run_e2e.py
+++ b/web/test/e2e/stovepipe/run_e2e.py
@@ -31,11 +31,12 @@ def _pagination_fixture_sql() -> str:
"?file=src%2Fmain#fragment" if i == 51 else ""
)
state = "failed" if i == 51 else "succeeded"
+ project_count, broken_project_count = (51, 2) if i == 51 else (0, 0)
statements.append(
"INSERT INTO request_summary "
"(queue,request_id,uri,base_uri,state,request_version,"
- "state_timestamp_ms,version,accepted_at_ms,outcome_reason) "
- f"VALUES ('web/pagination','{i}','{uri}','','{state}',1,{1000+i},1,{1000+i},'');"
+ "state_timestamp_ms,version,accepted_at_ms,outcome_reason,project_count,broken_project_count) "
+ f"VALUES ('web/pagination','{i}','{uri}','','{state}',1,{1000+i},1,{1000+i},'',{project_count},{broken_project_count});"
)
statements.append(
f"INSERT INTO request_acceptance VALUES ('web/pagination',{1000+i},'{i}');"
diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts
index 050ba3a71..c54079ace 100644
--- a/web/test/e2e/stovepipe/stovepipe.spec.ts
+++ b/web/test/e2e/stovepipe/stovepipe.spec.ts
@@ -22,6 +22,8 @@ test.describe("server-rendered styling", () => {
test("renders request status and views without JavaScript", async ({ page }) => {
await page.goto("/web%2Fpagination/request/51");
const status = page.getByRole("region", { name: "Request status" });
+ await expect(status.getByText("Broken projects", { exact: true })).toBeVisible();
+ await expect(status.getByText("2 of 51", { exact: true })).toBeVisible();
await expect(status.getByText("Repository result", { exact: true })).toBeVisible();
await expect(status.getByText("Partial Breakage", { exact: true })).toBeVisible();
await expect(status.getByText("git://web/pagination/51?file=src%2Fmain#fragment", { exact: true })).toBeVisible();
@@ -280,6 +282,8 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj
.getByRole("link", { name: "web/pagination", exact: true })
.click();
await expect(requests.locator("tbody tr")).toHaveCount(50);
+ await expect(requests.getByRole("columnheader", { name: "Broken projects", exact: true })).toBeVisible();
+ await expect(requests.getByText("2 of 51", { exact: true })).toBeVisible();
await page.screenshot({
path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-queue.png`,
fullPage: true,
@@ -310,6 +314,7 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj
).toBeVisible();
await expect(status.getByText("Accepted", { exact: true })).toBeVisible();
await expect(status.getByText("Updated", { exact: true })).toBeVisible();
+ await expect(status.getByText("2 of 51", { exact: true })).toBeVisible();
await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "History", exact: true }).click();
await expect(
page.getByRole("table", { name: "Request history" }).getByText("accepted", {
diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs
index 6b1750cf9..4855fd0ed 100644
--- a/web/test/package/react18/react18-runtime.test.mjs
+++ b/web/test/package/react18/react18-runtime.test.mjs
@@ -56,7 +56,7 @@ test("renders and hydrates packaged pages with React 18 and host themes", async
projectResultsComplete: true, nextPageToken: "",
projects: [{ project: "example-project", result: { case: "breakageDegree", value: 0 } }],
}),
- getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "" }, events: [] }),
+ getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "", projectCount: 0, brokenProjectCount: 0 }, events: [] }),
} });
for (const href of ["/repo%2Fmain", "/repo%2Fmain/request/1", "/repo%2Fmain/request/1?view=history"]) {
const url = new URL(href, "https://submitqueue.test");