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
42 changes: 42 additions & 0 deletions web/service/stovepipe/src/app/api/queue-requests/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "./route";

const { handle } = vi.hoisted(() => ({ handle: vi.fn() }));
vi.mock("../../../server/host", () => ({ resolveHost: () => ({ handle }), onLog: vi.fn() }));
const authorization = `Basic ${Buffer.from("test:test").toString("base64")}`;
function request(path: string, authorized = true) {
return new Request(`http://localhost/api/queue-requests?${new URLSearchParams({ path, page: "opaque+/=" })}`, {
headers: authorized ? { authorization } : {},
});
}
beforeEach(() => {
vi.stubEnv("STOVEPIPE_WEB_TOKEN", "test");
handle.mockReset().mockResolvedValue({ kind: "render", model: { kind: "queue", requests: { ok: true, data: { requests: [], nextHref: null } } } });
});
afterEach(() => vi.unstubAllEnvs());

it("requires authentication before loading a continuation", async () => {
const response = await GET(request("/repo%2Fmain", false));
expect(response.status).toBe(401);
expect(response.headers.get("www-authenticate")).toContain("Basic");
expect(handle).not.toHaveBeenCalled();
});
it("uses the authorized page loader, preserves the opaque cursor, and disables caching", async () => {
const response = await GET(request("/repo%2Fmain"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ requests: [], nextHref: null });
expect(handle).toHaveBeenCalledWith(expect.objectContaining({ path: "/repo%2Fmain", search: new URLSearchParams({ page: "opaque+/=" }), signal: expect.any(AbortSignal) }));
});
it("does not load request-detail routes through the queue endpoint", async () => {
expect((await GET(request("/repo%2Fmain/request/1"))).status).toBe(400);
expect(handle).not.toHaveBeenCalled();
});
it.each([
[{ kind: "not-found" }, 404],
[{ kind: "forbidden" }, 403],
[{ kind: "render", model: { kind: "queue", requests: { ok: false, error: "unavailable" } } }, 503],
])("preserves failed page loads (%j)", async (result, status) => {
handle.mockResolvedValueOnce(result);
expect((await GET(request("/repo%2Fmain"))).status).toBe(status);
});
18 changes: 18 additions & 0 deletions web/service/stovepipe/src/app/api/queue-requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { authenticationChallenge, isAuthorized, loadAuthConfiguration } from "../../../server/auth";
import { onLog, resolveHost } from "../../../server/host";

export const dynamic = "force-dynamic";

export async function GET(request: Request): Promise<Response> {
if (!isAuthorized(request.headers.get("authorization"), loadAuthConfiguration())) return authenticationChallenge();
const url = new URL(request.url);
const path = url.searchParams.get("path");
if (!path || !/^\/[^/]+$/u.test(path)) return new Response(null, { status: 400 });
const result = await resolveHost().handle({
path, search: new URLSearchParams({ page: url.searchParams.get("page") ?? "" }), signal: request.signal, onLog,
});
if (result.kind !== "render") return new Response(null, { status: result.kind === "forbidden" ? 403 : 404 });
if (result.model.kind !== "queue") return new Response(null, { status: 400 });
if (!result.model.requests.ok) return new Response(null, { status: 503 });
return Response.json(result.model.requests.data, { headers: { "cache-control": "no-store" } });
}
3 changes: 2 additions & 1 deletion web/service/stovepipe/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { ThemeProvider } from "@submitqueue/web-service-shared/theme";
import { QueueProvider } from "../next/queue-provider";
import "@submitqueue/web-stovepipe/styles.css";
export const metadata = {
title: "Stovepipe",
Expand All @@ -8,7 +9,7 @@ export const metadata = {
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0 }}><ThemeProvider>{children}</ThemeProvider></body>
<body style={{ margin: 0 }}><ThemeProvider><QueueProvider>{children}</QueueProvider></ThemeProvider></body>
</html>
);
}
16 changes: 16 additions & 0 deletions web/service/stovepipe/src/next/queue-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import type { ReactNode } from "react";
import { StovepipeQueueProvider, type QueuePageLoader } from "@submitqueue/web-stovepipe";

const loadPage: QueuePageLoader = async (href, signal) => {
const continuation = new URL(href, window.location.origin);
const search = new URLSearchParams({ path: continuation.pathname, page: continuation.searchParams.get("page") ?? "" });
const response = await fetch(`/api/queue-requests?${search}`, { signal, cache: "no-store" });
if (!response.ok) throw new Error("Queue page unavailable");
return response.json();
};

export function QueueProvider({ children }: { children: ReactNode }) {
return <StovepipeQueueProvider loadPage={loadPage}>{children}</StovepipeQueueProvider>;
}
6 changes: 5 additions & 1 deletion web/stovepipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Instrumentation belongs in the host: wrap `handle` to observe page results and i

Use the server entry's `createStovepipeWeb` to load a page and render its model with `StovepipeApp`. Mount beneath `WebNavigationProvider` when the host supplies client navigation and refresh. Without it, links and refresh use ordinary document navigation. Import `@submitqueue/web-stovepipe/styles.css` for long-identifier wrapping, and supply BaseUI's `BaseProvider` and Styletron's `Provider`. The host owns the theme and styling engine, including SSR stylesheet extraction and hydration; the library creates neither. BaseUI, React, React DOM, and Styletron React are peer dependencies.

Mount `StovepipeQueueProvider` above route changes with a `loadPage(href, signal)` callback that returns a `QueueRequestsModel` through the host's authenticated transport. Queue lists then load older rows automatically near the bottom, with a keyboard-accessible Load more button and explicit retry after failure. The provider retains loaded rows and scroll positions across detail visits. Without JavaScript or a loader, continuation links navigate normally. The reference host supplies an authenticated, uncached JSON route.

## Routes and data

Request pages use `/<queue>/request/<request-id>` and load both the current summary and events with `GetRequestHistoryByID`. The summary is displayed above history and is not reconstructed from retained events. Queue pagination uses the backend's opaque continuations, and the latest URL carries no time window.
Expand All @@ -33,4 +35,6 @@ in this package; hosts only adapt their transport representation.

## Refresh and presentation

Refresh is manual by default, with optional ten-second polling. Failed refreshes retain successful results on the same route; changing routes clears them. Timestamps use UTC. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim.
Refresh is manual by default, with optional ten-second polling. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps use UTC. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim.

Queue refreshes update the status of matching displayed requests while preserving their positions and loaded older pages. New arrivals appear behind a Show latest requests action that starts a fresh list. A full browser reload also starts a fresh list; older-page URLs retain their explicit cursor.
11 changes: 7 additions & 4 deletions web/stovepipe/entity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface RequestSummaryModel {
/** Stable backend outcome reason, empty when absent. */
outcomeReason: string;
}
export interface QueueRequestsModel {
/** Requests on this page, newest accepted first. */
requests: RequestSummaryModel[];
/** Opaque continuation URL; null on the final page. */
nextHref: string | null;
}
export interface HistoryEventModel {
/** Stable event identifier within the request. */
id: string;
Expand Down Expand Up @@ -75,10 +81,7 @@ export type StovepipePageModel = PageBase &
(
| {
kind: "queue";
requests: ReadResult<{
requests: RequestSummaryModel[];
nextHref: string | null;
}>;
requests: ReadResult<QueueRequestsModel>;
}
| {
kind: "request";
Expand Down
3 changes: 2 additions & 1 deletion web/stovepipe/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";
export { StovepipeApp } from "./view/app.js";
export type { StovepipePageModel } from "./entity/index.js";
export type { QueueRequestsModel, StovepipePageModel } from "./entity/index.js";
export { StovepipeQueueProvider, type QueuePageLoader } from "./view/queue.js";
export {
WebNavigationProvider,
type WebLinkProps,
Expand Down
5 changes: 3 additions & 2 deletions web/stovepipe/view/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/
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 { RequestDetails, History } from "./pages.js";
import { QueueRequests } from "./queue.js";
import { ProjectResults, RecordedResult } from "./projects.js";

function Page({ model }: { model: StovepipePageModel }) {
Expand Down Expand Up @@ -59,7 +60,7 @@ function Page({ model }: { model: StovepipePageModel }) {
)}
{model.kind === "queue" ? (
<Section result={model.requests}>
{(data) => data && <RequestList {...data} />}
{(data) => data && <QueueRequests initial={data} routeKey={model.key} />}
</Section>
) : (
<Section result={model.history}>
Expand Down
7 changes: 0 additions & 7 deletions web/stovepipe/view/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ function RequestTimestamp({ value }: { value: string }) {

export function RequestList({
requests,
nextHref,
}: {
requests: RequestSummaryModel[];
nextHref: string | null;
}) {
if (!requests.length)
return <ParagraphSmall color="contentSecondary">No retained requests on this queue.</ParagraphSmall>;
Expand Down Expand Up @@ -63,11 +61,6 @@ export function RequestList({
</StyledTableBodyRow>
))}
</Table>
{nextHref && (
<ParagraphSmall>
<Link href={nextHref}>Older requests →</Link>
</ParagraphSmall>
)}
</>
);
}
Expand Down
102 changes: 102 additions & 0 deletions web/stovepipe/view/queue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "../../platform/test/rtl.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { QueueRequestsModel } from "../entity/index.js";
import { QueueRequests, StovepipeQueueProvider } from "./queue.js";

const nextHref = "/repo%2Fmain?page=opaque%2B%2F%3D";
function page(ids: string[], next: string | null = nextHref): QueueRequestsModel {
return { requests: ids.map(id => ({
id, href: `/repo%2Fmain/request/${id}`, changeUri: `git://repo/${id}`, baseUri: "", state: "accepted",
acceptedAtMs: "1000", updatedAtMs: "1000", outcomeReason: "",
})), nextHref: next };
}
function displayedIds() {
return within(screen.getByRole("table", { name: "Requests" })).getAllByRole("link").map(link => link.textContent);
}
let intersect: () => void;
beforeEach(() => {
vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
vi.stubGlobal("IntersectionObserver", class {
constructor(callback: IntersectionObserverCallback) {
intersect = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
observe() {}
disconnect() {}
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("loads on intersection once, appends unique requests, and stops at the end", async () => {
let resolve!: (result: QueueRequestsModel) => void;
const loadPage = vi.fn(() => new Promise<QueueRequestsModel>(complete => {
resolve = complete;
}));
render(<StovepipeQueueProvider loadPage={loadPage}><QueueRequests routeKey="queue" initial={page(["3", "2"])} /></StovepipeQueueProvider>);
await act(async () => {
intersect();
intersect();
});
expect(loadPage).toHaveBeenCalledTimes(1);
expect(loadPage).toHaveBeenCalledWith(nextHref, expect.any(AbortSignal));
expect(displayedIds()).toEqual(["3", "2"]);
const loadMore = screen.getByRole("button", { name: "Load more requests" });
expect(loadMore).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("status")).toHaveTextContent("Loading older requests…");
expect(screen.getByRole("progressbar", { name: "Loading older requests" })).toBeVisible();
fireEvent.click(loadMore);
expect(loadPage).toHaveBeenCalledTimes(1);
await act(async () => resolve(page(["2", "1", "1"], null)));
expect(displayedIds()).toEqual(["3", "2", "1"]);
expect(screen.getByRole("status")).toHaveTextContent("All available requests loaded");
expect(screen.queryByRole("progressbar")).toBeNull();
expect(screen.queryByRole("button", { name: /Load more/ })).toBeNull();
});

it("retains rows after a failed load and retries the same continuation by button", async () => {
const loadPage = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValueOnce(page(["1"], null));
render(<StovepipeQueueProvider loadPage={loadPage}><QueueRequests routeKey="queue" initial={page(["2"])} /></StovepipeQueueProvider>);
await act(async () => intersect());
expect(displayedIds()).toEqual(["2"]);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load older requests");
expect(screen.queryByRole("progressbar")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry loading older requests" }));
await waitFor(() => expect(displayedIds()).toEqual(["2", "1"]));
expect(loadPage.mock.calls.map(call => call[0])).toEqual([nextHref, nextHref]);
expect(screen.queryByRole("alert")).toBeNull();
});

it("aborts a departing queue, restores its loaded rows, and keeps new arrivals behind an explicit action", async () => {
const initial = page(["3", "2"]);
const loadPage = vi.fn().mockResolvedValueOnce(page(["1"], "/repo%2Fmain?page=last"));
function App({ visible = true, latest = initial }: { visible?: boolean; latest?: QueueRequestsModel }) {
return <StovepipeQueueProvider loadPage={loadPage}>{visible && <QueueRequests routeKey="queue" initial={latest} />}</StovepipeQueueProvider>;
}
const { rerender } = render(<App />);
await act(async () => intersect());
expect(displayedIds()).toEqual(["3", "2", "1"]);
let signal: AbortSignal | undefined;
let finish!: (result: QueueRequestsModel) => void;
loadPage.mockImplementationOnce((_href: string, abort: AbortSignal) => {
signal = abort;
return new Promise<QueueRequestsModel>(resolve => {
finish = resolve;
});
});
await act(async () => intersect());
rerender(<App visible={false} />);
expect(signal?.aborted).toBe(true);
await act(async () => finish(page(["0"], null)));
const latest = page(["4", "3"]);
latest.requests[1]!.state = "succeeded";
rerender(<App latest={latest} />);
expect(displayedIds()).toEqual(["3", "2", "1"]);
expect(screen.getByText("succeeded")).toBeVisible();
expect(screen.queryByRole("link", { name: "0" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Show latest requests/ }));
expect(displayedIds()).toEqual(["4", "3"]);
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
});
Loading
Loading