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(