From d45fa6f13eb1d92e4999ff990341df47d5142674 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Thu, 22 Oct 2026 21:24:03 +0000 Subject: [PATCH] feat(stovepipe): load older queue requests on scroll [draft] Summary: ## Why? Browsing a long Stovepipe queue currently requires navigating between pages. Load older requests as users scroll, with clear loading feedback that stays stable while batches arrive. ## What? - Append 50-request batches using existing continuation URLs, retaining rows and scroll position across request-detail visits. - Add an authenticated, uncached host endpoint; prevent overlapping loads, duplicate rows, and repeated continuation loops, and offer explicit retry after failures. - Show a delayed, fading BaseUI spinner in a reserved footer so fast loads do not flash and controls stay steady. Respect reduced motion and retain link pagination without JavaScript. - Refresh visible statuses without reordering rows; offer new arrivals through Show latest requests. - Cover scrolling, failures, navigation, authentication, accessibility, narrow layouts, and loading visuals in browser tests. Test Plan: Manually scrolled through 500 seeded requests in ten batches, verifying all IDs appeared once without clicking Load more. Checked the loading indicator on desktop and narrow viewports, stable footer height, reduced motion, and request details with history and project results. For a reproducible real-stack browser run with seeded pagination data, run `./tool/bazel test //web/test/e2e/stovepipe:web_test --test_output=errors` with Docker running. To run the UI locally, use `make local-stovepipe-web-start`, open the printed Web UX URL, and sign in with `test` / `test` (or the configured `STOVEPIPE_WEB_TOKEN`). Ingest more than 50 requests into a queue to exercise scrolling; the regular local stack starts without the browser-test fixtures. --- .../src/app/api/queue-requests/route.test.ts | 42 +++++ .../src/app/api/queue-requests/route.ts | 18 ++ web/service/stovepipe/src/app/layout.tsx | 3 +- .../stovepipe/src/next/queue-provider.tsx | 16 ++ web/stovepipe/README.md | 6 +- web/stovepipe/entity/index.ts | 11 +- web/stovepipe/index.ts | 3 +- web/stovepipe/view/app.tsx | 5 +- web/stovepipe/view/pages.tsx | 7 - web/stovepipe/view/queue.test.tsx | 102 ++++++++++ web/stovepipe/view/queue.tsx | 177 ++++++++++++++++++ web/test/e2e/stovepipe/stovepipe.spec.ts | 110 ++++++++++- 12 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 web/service/stovepipe/src/app/api/queue-requests/route.test.ts create mode 100644 web/service/stovepipe/src/app/api/queue-requests/route.ts create mode 100644 web/service/stovepipe/src/next/queue-provider.tsx create mode 100644 web/stovepipe/view/queue.test.tsx create mode 100644 web/stovepipe/view/queue.tsx diff --git a/web/service/stovepipe/src/app/api/queue-requests/route.test.ts b/web/service/stovepipe/src/app/api/queue-requests/route.test.ts new file mode 100644 index 000000000..b33f10f0b --- /dev/null +++ b/web/service/stovepipe/src/app/api/queue-requests/route.test.ts @@ -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); +}); diff --git a/web/service/stovepipe/src/app/api/queue-requests/route.ts b/web/service/stovepipe/src/app/api/queue-requests/route.ts new file mode 100644 index 000000000..e67275683 --- /dev/null +++ b/web/service/stovepipe/src/app/api/queue-requests/route.ts @@ -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 { + 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" } }); +} diff --git a/web/service/stovepipe/src/app/layout.tsx b/web/service/stovepipe/src/app/layout.tsx index 9b8039395..a6f64a4e3 100644 --- a/web/service/stovepipe/src/app/layout.tsx +++ b/web/service/stovepipe/src/app/layout.tsx @@ -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", @@ -8,7 +9,7 @@ export const metadata = { export default function Layout({ children }: { children: ReactNode }) { return ( - {children} + {children} ); } diff --git a/web/service/stovepipe/src/next/queue-provider.tsx b/web/service/stovepipe/src/next/queue-provider.tsx new file mode 100644 index 000000000..56bfcfc43 --- /dev/null +++ b/web/service/stovepipe/src/next/queue-provider.tsx @@ -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 {children}; +} diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 671aa4aa7..326e74e03 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -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 `//request/` 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. @@ -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. diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index 30760a4ba..564d7cce4 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -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; @@ -75,10 +81,7 @@ export type StovepipePageModel = PageBase & ( | { kind: "queue"; - requests: ReadResult<{ - requests: RequestSummaryModel[]; - nextHref: string | null; - }>; + requests: ReadResult; } | { kind: "request"; diff --git a/web/stovepipe/index.ts b/web/stovepipe/index.ts index 80ad8685c..1ad6a82f5 100644 --- a/web/stovepipe/index.ts +++ b/web/stovepipe/index.ts @@ -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, diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index 7d785ab03..f297dad3c 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -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 }) { @@ -59,7 +60,7 @@ function Page({ model }: { model: StovepipePageModel }) { )} {model.kind === "queue" ? (
- {(data) => data && } + {(data) => data && }
) : (
diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx index 933c59566..fb8675dbc 100644 --- a/web/stovepipe/view/pages.tsx +++ b/web/stovepipe/view/pages.tsx @@ -20,10 +20,8 @@ function RequestTimestamp({ value }: { value: string }) { export function RequestList({ requests, - nextHref, }: { requests: RequestSummaryModel[]; - nextHref: string | null; }) { if (!requests.length) return No retained requests on this queue.; @@ -63,11 +61,6 @@ export function RequestList({ ))} - {nextHref && ( - - Older requests → - - )} ); } diff --git a/web/stovepipe/view/queue.test.tsx b/web/stovepipe/view/queue.test.tsx new file mode 100644 index 000000000..14a116aac --- /dev/null +++ b/web/stovepipe/view/queue.test.tsx @@ -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(complete => { + resolve = complete; + })); + render(); + 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(); + 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 {visible && }; + } + const { rerender } = render(); + 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(resolve => { + finish = resolve; + }); + }); + await act(async () => intersect()); + rerender(); + expect(signal?.aborted).toBe(true); + await act(async () => finish(page(["0"], null))); + const latest = page(["4", "3"]); + latest.requests[1]!.state = "succeeded"; + rerender(); + 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); +}); diff --git a/web/stovepipe/view/queue.tsx b/web/stovepipe/view/queue.tsx new file mode 100644 index 000000000..1d5483d96 --- /dev/null +++ b/web/stovepipe/view/queue.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Block } from "baseui/block"; +import { Button, KIND, SIZE } from "baseui/button"; +import { Spinner } from "baseui/spinner"; +import { ParagraphSmall, ParagraphXSmall } from "baseui/typography"; +import type { QueueRequestsModel } from "../entity/index.js"; +import { Link } from "./components.js"; +import { RequestList } from "./pages.js"; + +const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** Fetches a continuation through the host's authenticated transport. */ +export type QueuePageLoader = (href: string, signal: AbortSignal) => Promise; + +interface QueueSnapshot extends QueueRequestsModel { + latest: QueueRequestsModel; + firstPageIds: string[]; + loadedHrefs: string[]; +} +interface SavedQueue { + snapshot: QueueSnapshot; + scrollY: number; +} +const QueueContext = createContext<{ + loadPage: QueuePageLoader; + pages: Map; +} | null>(null); + +/** Mount above route changes to retain loaded queue rows and scroll positions. */ +export function StovepipeQueueProvider({ loadPage, children }: { loadPage: QueuePageLoader; children: ReactNode }) { + const pages = useRef(new Map()); + const value = useMemo(() => ({ loadPage, pages: pages.current }), [loadPage]); + return {children}; +} + +function createQueueSnapshot(page: QueueRequestsModel): QueueSnapshot { + return { ...page, latest: page, firstPageIds: page.requests.map(request => request.id), loadedHrefs: [] }; +} + +export function QueueRequests({ initial, routeKey }: { initial: QueueRequestsModel; routeKey: string }) { + const context = useContext(QueueContext); + const [snapshot, setSnapshot] = useState(() => context?.pages.get(routeKey)?.snapshot ?? createQueueSnapshot(initial)); + const [hydrated, setHydrated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const sentinel = useRef(null); + const inFlight = useRef(null); + const saved = useRef({ snapshot, scrollY: context?.pages.get(routeKey)?.scrollY ?? 0 }); + saved.current.snapshot = snapshot; + + if (snapshot.latest !== initial) { + const current = new Map(initial.requests.map(request => [request.id, request])); + setSnapshot({ ...snapshot, latest: initial, requests: snapshot.requests.map(request => current.get(request.id) ?? request) }); + } + + useBrowserLayoutEffect(() => { + setHydrated(true); + const previous = context?.pages.get(routeKey); + const frame = previous ? requestAnimationFrame(() => window.scrollTo(0, previous.scrollY)) : null; + const recordScroll = () => { + saved.current.scrollY = window.scrollY; + }; + window.addEventListener("scroll", recordScroll, { passive: true }); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + window.removeEventListener("scroll", recordScroll); + // Save before navigation can shrink the document and reset its scroll offset. + inFlight.current?.abort(); + context?.pages.set(routeKey, saved.current); + }; + }, [context, routeKey]); + + const loadOlderRequests = useCallback(async () => { + const href = snapshot.nextHref; + if (!context || !href || inFlight.current) return; + const controller = new AbortController(); + inFlight.current = controller; + setLoading(true); + setError(false); + try { + const next = await context.loadPage(href, controller.signal); + if (controller.signal.aborted) return; + if (next.nextHref && [href, ...snapshot.loadedHrefs].includes(next.nextHref)) throw new Error("Repeated continuation"); + setSnapshot(current => { + if (current.nextHref !== href) return current; + const existing = new Set(current.requests.map(request => request.id)); + const additions = next.requests.filter(request => { + if (existing.has(request.id)) return false; + existing.add(request.id); + return true; + }); + return { ...current, requests: [...current.requests, ...additions], nextHref: next.nextHref, loadedHrefs: [...current.loadedHrefs, href] }; + }); + } catch { + if (!controller.signal.aborted) setError(true); + } finally { + if (inFlight.current === controller) { + inFlight.current = null; + setLoading(false); + } + } + }, [context, snapshot.nextHref, snapshot.loadedHrefs]); + + useEffect(() => { + if (!context || !snapshot.nextHref || error || loading || !sentinel.current || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) void loadOlderRequests(); + }, { rootMargin: "400px" }); + observer.observe(sentinel.current); + return () => observer.disconnect(); + }, [context, snapshot.nextHref, error, loading, loadOlderRequests]); + + let paginationMessage: string; + if (loading) { + paginationMessage = "Loading older requests…"; + } else if (error) { + paginationMessage = "Loading paused"; + } else if (snapshot.nextHref && context && hydrated && typeof IntersectionObserver !== "undefined") { + paginationMessage = "Scroll for more requests"; + } else if (snapshot.nextHref) { + paginationMessage = "More requests available"; + } else if (snapshot.requests.length) { + paginationMessage = "All available requests loaded"; + } else { + paginationMessage = "No requests to load"; + } + + const newRequests = initial.requests.some(request => !snapshot.firstPageIds.includes(request.id)); + return <> + {newRequests && + + } + +
+ {error && Could not load older requests. Your displayed requests are unchanged.} + + + + + {loading && + } + + + {paginationMessage} + + + {snapshot.requests.length} displayed requests + + + {snapshot.nextHref && (context && hydrated + ? + : Older requests →)} + + +
+ ; +} diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index 6b2f55ae8..f878fc33a 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -17,6 +17,7 @@ test.describe("server-rendered styling", () => { await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "32px"); await expect(page.getByRole("button", { name: "Refresh", exact: true })).toHaveCSS("background-color", "rgb(0, 0, 0)"); await expect(page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr")).toHaveCount(50); + await expect(page.getByRole("status")).toContainText("More requests available"); }); test("separates request status fields before hydration", async ({ page }) => { await page.goto("/web%2Fpagination/request/51"); @@ -36,6 +37,14 @@ test.describe("server-rendered styling", () => { expect(row.separator).toBeGreaterThan(0); } }); + test("keeps continuation links usable without JavaScript", async ({ page }) => { + await page.goto("/web%2Fpagination"); + await page.getByRole("link", { name: "Older requests →" }).click(); + await expect(page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr")).toHaveCount(1); + expect(new URL(page.url()).searchParams.get("page")).toBeTruthy(); + await page.reload(); + await expect(page.getByRole("link", { name: "1", exact: true })).toBeVisible(); + }); test("uses the available width on a wide screen", async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); await page.goto("/web%2Fpagination"); @@ -58,6 +67,97 @@ test("keeps queue tables inside a narrow viewport", async ({ page }) => { await expect.poll(() => history.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); +test("shows loading progress without shifting the controls and respects reduced motion", async ({ page }) => { + let releaseResponse!: () => void; + const responseGate = new Promise(resolve => { releaseResponse = resolve; }); + let acknowledgeRequest!: () => void; + const requested = new Promise(resolve => { acknowledgeRequest = resolve; }); + await page.route("**/api/queue-requests?*", async route => { + acknowledgeRequest(); + await responseGate; + await route.continue(); + }); + try { + await page.goto("/web%2Fpagination"); + const rows = page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr"); + const pagination = page.getByRole("region", { name: "Queue pagination", exact: true }); + const control = page.getByRole("button", { name: "Load more requests", exact: true }); + const progress = page.getByRole("progressbar", { name: "Loading older requests", exact: true }); + const appearance = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, height: rect.height, color: style.color, background: style.backgroundColor }; + }; + const before = await control.evaluate(appearance); + const footerHeight = await pagination.evaluate(element => element.getBoundingClientRect().height); + await expect(progress).toHaveCount(0); + await control.scrollIntoViewIfNeeded(); + await requested; + await expect(control).toHaveAttribute("aria-disabled", "true"); + await expect(page.getByRole("status")).toContainText("Loading older requests…"); + await expect(progress).toBeVisible(); + await expect(progress).toHaveCSS("opacity", "1"); + await expect(rows).toHaveCount(50); + expect(await control.evaluate(appearance)).toEqual(before); + expect(await pagination.evaluate(element => element.getBoundingClientRect().height)).toBe(footerHeight); + expect(await progress.evaluate(element => element.getAnimations({ subtree: true }).some(animation => animation.effect?.getTiming().iterations === Infinity))).toBe(true); + await page.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-loading.png` }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect.poll(() => progress.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0); + await page.setViewportSize({ width: 390, height: 844 }); + await progress.scrollIntoViewIfNeeded(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-loading-narrow.png` }); + const narrowFooterHeight = await pagination.evaluate(element => element.getBoundingClientRect().height); + releaseResponse(); + await expect(rows).toHaveCount(51); + await expect(progress).toHaveCount(0); + expect(await pagination.evaluate(element => element.getBoundingClientRect().height)).toBe(narrowFooterHeight); + } finally { + releaseResponse(); + } +}); +test("loads older requests on scroll, retries failures, and restores the list after a detail visit", async ({ page }) => { + await page.goto("/web%2Fpagination"); + const rows = page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr"); + await expect(rows).toHaveCount(50); + const continuation = "**/api/queue-requests?*"; + let calls = 0; + await page.route(continuation, async route => { + calls++; + await route.fulfill({ status: 503 }); + }); + await page.getByRole("button", { name: "Load more requests", exact: true }).scrollIntoViewIfNeeded(); + await expect(page.getByRole("alert").filter({ hasText: "Could not load older requests" })).toBeVisible(); + await expect(rows).toHaveCount(50); + expect(calls).toBe(1); + await page.unroute(continuation); + await page.getByRole("button", { name: "Retry loading older requests" }).click(); + await expect(rows).toHaveCount(51); + await expect(page.getByRole("status")).toContainText("All available requests loaded"); + expect(new URL(page.url()).search).toBe(""); + const scrollY = await page.evaluate(() => window.scrollY); + await page.getByRole("link", { name: "1", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Request 1", exact: true })).toBeVisible(); + await page.goBack(); + await expect(rows).toHaveCount(51); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(scrollY - 200); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + + await page.evaluate(() => window.scrollTo(0, 0)); + await page.reload(); + await expect(rows).toHaveCount(50); + await page.getByRole("button", { name: "Load more requests", exact: true }).scrollIntoViewIfNeeded(); + await expect(rows).toHaveCount(51); + expect(new URL(page.url()).search).toBe(""); +}); +test("challenges anonymous continuation requests", async () => { + const url = new URL("/api/queue-requests?path=%2Fweb%252Fpagination&page=opaque", process.env.STOVEPIPE_E2E_WEB_URL!); + const response = await fetch(url); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("Basic"); +}); test("real ingestion, queue selection, cursors, request summaries, history, project results and refresh", async ({ page, }) => { @@ -209,13 +309,11 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj 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 test.step("Scroll through the queue, then select an empty queue", async () => { await page.getByRole("link", { name: "← Queue requests" }).click(); - await page.getByRole("link", { name: "Older requests →" }).click(); - await expect(requests.locator("tbody tr")).toHaveCount(1); - expect(new URL(page.url()).searchParams.has("page")).toBe(true); - await page.reload(); - await expect(requests.locator("tbody tr")).toHaveCount(1); + await page.getByRole("button", { name: "Load more requests", exact: true }).scrollIntoViewIfNeeded(); + await expect(requests.locator("tbody tr")).toHaveCount(51); + expect(new URL(page.url()).searchParams.has("page")).toBe(false); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await queues.getByRole("link", { name: "web/empty", exact: true }).click(); await expect(