From 6b1ff8cb150b1edfba80952a8ea5c160a2e702f1 Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Tue, 10 Nov 2026 17:44:47 +0000 Subject: [PATCH 1/2] feat(web): Compact the auto-refresh control --- doc/rfc/web-ui.md | 2 +- web/platform/auto-refresh.test.tsx | 61 ++++++++++++++++---- web/platform/auto-refresh.tsx | 58 ++++++++++++++----- web/stovepipe/README.md | 2 +- web/stovepipe/view/app.test.tsx | 6 +- web/stovepipe/view/refresh.test.tsx | 12 ++-- web/submitqueue/README.md | 2 +- web/submitqueue/view/pages.test.tsx | 5 +- web/submitqueue/view/web-app.test.tsx | 4 +- web/test/e2e/stovepipe/stovepipe.spec.ts | 40 +++++++------ web/test/e2e/submitqueue/submitqueue.spec.ts | 14 ++--- 11 files changed, 141 insertions(+), 65 deletions(-) diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md index ba6767f34..5a052ded6 100644 --- a/doc/rfc/web-ui.md +++ b/doc/rfc/web-ui.md @@ -66,7 +66,7 @@ Summary is the default request view, and `?view=history` makes History shareable **Time zones.** Timestamps default to the browser time zone. Clicking a dotted-underlined timestamp opens a shared popover with local and UTC values, ISO copying, and a Local/UTC display choice that applies to all timestamps and persists in local storage. Server rendering uses UTC until hydration resolves the browser zone and preference. -**Refresh and polling.** The header has an opt-in ten-second auto-refresh checkbox, off by default. Users reload the browser for manual updates. A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. When enabled, a queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling. +**Refresh and polling.** The header has an opt-in circular-arrow auto-refresh toggle, off by default. Its selected state stays visible while enabled, BaseUI tooltips identify Enable auto-refresh and Disable auto-refresh, and it animates only during a refresh. The interval remains ten seconds. Users reload the browser for manual updates. A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. When enabled, a queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling. **Errors and values.** Gateway failures map from gRPC status: invalid argument and resource exhausted are user errors, not found is a missing resource, unavailable and deadline exceeded are transient, and everything else is internal. A transport with another error shape supplies its own classifier. Raw gateway messages are logged, never rendered, and views keep the last good data on transient failures. Status values stay strings with a display table and an unknown fallback. `int64` values arrive as bigint, number, string, or `Long` and become milliseconds after a safe-range check. diff --git a/web/platform/auto-refresh.test.tsx b/web/platform/auto-refresh.test.tsx index abf358296..2a21d668e 100644 --- a/web/platform/auto-refresh.test.tsx +++ b/web/platform/auto-refresh.test.tsx @@ -12,10 +12,49 @@ describe("AutoRefresh", () => { afterEach(() => vi.useRealTimers()); + it("exposes a compact toggle with an action label and a persistent selected state", () => { + render( undefined} />); + const toggle = screen.getByRole("button", { name: "Enable auto-refresh" }); + expect(toggle).not.toHaveAttribute("title"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(toggle); + expect(toggle).toHaveAccessibleName("Disable auto-refresh"); + expect(toggle).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(toggle); + expect(toggle).toHaveAccessibleName("Enable auto-refresh"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + }); + + it("shows the action tooltip after a short hover and updates it when toggled", async () => { + render( undefined} />); + const toggle = screen.getByRole("button", { name: "Enable auto-refresh" }); + fireEvent.mouseEnter(toggle); + await act(async () => vi.advanceTimersByTime(149)); + expect(screen.queryByRole("tooltip")).toBeNull(); + await act(async () => vi.advanceTimersByTime(1)); + expect(screen.getByRole("tooltip")).toHaveTextContent("Enable auto-refresh"); + fireEvent.click(toggle); + expect(screen.getByRole("tooltip")).toHaveTextContent("Disable auto-refresh"); + fireEvent.mouseLeave(toggle); + await act(async () => vi.advanceTimersByTime(100)); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("shows the tooltip on keyboard focus", async () => { + render( undefined} />); + const toggle = screen.getByRole("button", { name: "Enable auto-refresh" }); + fireEvent.keyDown(document, { key: "Tab", keyCode: 9 }); + act(() => toggle.focus()); + expect(screen.getByRole("tooltip")).toHaveTextContent("Enable auto-refresh"); + act(() => toggle.blur()); + await act(async () => vi.advanceTimersByTime(100)); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + it("uses the interval plus jitter and continues polling", async () => { const refresh = vi.fn(async () => undefined); render( 0.5} refresh={refresh} />); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(2_249)); expect(refresh).not.toHaveBeenCalled(); @@ -29,12 +68,14 @@ describe("AutoRefresh", () => { let finish: (() => void) | undefined; const refresh = vi.fn(() => new Promise((resolve) => (finish = resolve))); render(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(100)); + expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); await act(async () => vi.advanceTimersByTime(500)); expect(refresh).toHaveBeenCalledTimes(1); await act(async () => finish?.()); + expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "false"); }); it("backs off transient failures up to the configured cap", async () => { @@ -49,7 +90,7 @@ describe("AutoRefresh", () => { transientFailureCount={4} />, ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(29_999)); expect(refresh).not.toHaveBeenCalled(); @@ -69,7 +110,7 @@ describe("AutoRefresh", () => { transientFailureCount={1} />, ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(200)); expect(refresh).toHaveBeenCalledTimes(1); @@ -86,7 +127,7 @@ describe("AutoRefresh", () => { it("backs off after a rejected refresh and recovers once a refresh succeeds", async () => { const refresh = vi.fn<() => Promise>().mockRejectedValueOnce(new Error("network")).mockResolvedValue(undefined); render( 0} refresh={refresh} />); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(100)); expect(refresh).toHaveBeenCalledTimes(1); @@ -104,7 +145,7 @@ describe("AutoRefresh", () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); const refresh = vi.fn(async () => undefined); render(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); expect(screen.getByText("Automatic refresh paused")).toBeTruthy(); await act(async () => vi.advanceTimersByTime(1_000)); @@ -119,7 +160,7 @@ describe("AutoRefresh", () => { Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); const refresh = vi.fn(async () => undefined); render(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(1_000)); expect(refresh).not.toHaveBeenCalled(); @@ -132,14 +173,14 @@ describe("AutoRefresh", () => { it("stops automatic polling for terminal detail views", async () => { const refresh = vi.fn(async () => undefined); const { rerender } = render(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(100)); expect(refresh).toHaveBeenCalledTimes(1); rerender(); await act(async () => vi.advanceTimersByTime(1_000)); expect(refresh).toHaveBeenCalledTimes(1); - expect(screen.getByRole("checkbox")).toBeDisabled(); + expect(screen.getByRole("button")).toBeDisabled(); }); it("does not poll a terminal list failure", async () => { @@ -153,7 +194,7 @@ describe("AutoRefresh", () => { transientFailureCount={1} />, ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button")); await act(async () => vi.advanceTimersByTime(1_000)); expect(refresh).not.toHaveBeenCalled(); diff --git a/web/platform/auto-refresh.tsx b/web/platform/auto-refresh.tsx index 4e681dd45..829276e93 100644 --- a/web/platform/auto-refresh.tsx +++ b/web/platform/auto-refresh.tsx @@ -1,10 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { Theme } from "baseui"; +import { styled, type Theme } from "baseui"; import { Block } from "baseui/block"; -import { Checkbox } from "baseui/checkbox"; -import { LabelXSmall } from "baseui/typography"; +import { Button, KIND, SHAPE, SIZE } from "baseui/button"; +import { StatefulTooltip } from "baseui/tooltip"; + +const RefreshIcon = styled<"svg", { $spinning: boolean }>("svg", ({ $spinning }) => ({ + animationName: $spinning ? { from: { transform: "rotate(0deg)" }, to: { transform: "rotate(360deg)" } } : "none", + animationDuration: "1s", animationIterationCount: "infinite", animationTimingFunction: "linear", + "@media (prefers-reduced-motion: reduce)": { animationName: "none" }, +})); export interface AutoRefreshProps { terminal?: boolean; @@ -102,19 +108,43 @@ export function AutoRefresh({ if (!available) return null; + const action = terminal ? "Auto-refresh stopped" : enabled ? "Disable auto-refresh" : "Enable auto-refresh"; + const failed = transientFailureCount > 0 || localFailures.current > 0; + const quiet = !terminal && !paused && !failed; return ( - - setEnabled(event.currentTarget.checked)} - overrides={{ - Root: { style: { alignItems: "center" } }, - Label: { style: ({ $theme }: { $theme: Theme }) => $theme.typography.LabelSmall }, - }}> - Auto refresh every {intervalMs / 1_000}s - - {enabled && + + + + + {enabled && {terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : refreshing ? "Refreshing…" : - transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"} - } + failed ? "Updates unavailable · retrying" : "Live"} + } ); } diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 71deeeca6..c69759117 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -41,7 +41,7 @@ Repository breakage, per-project breakage, and recording completion are separate ## Refresh and presentation -The header offers optional ten-second auto-refresh, off by default. Use browser reload for manual updates. Polling pauses while the page is hidden or offline. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps default to the browser's time zone. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. +The header offers optional ten-second auto-refresh, off by default. The circular-arrow button toggles polling, stays selected while enabled, and animates only during a refresh; BaseUI tooltips identify Enable auto-refresh and Disable auto-refresh. Use browser reload for manual updates. Polling pauses while the page is hidden or offline. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps default to the browser's time zone. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. 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/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 112f280fe..c089f5a84 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -108,7 +108,7 @@ it("retains issue data and auto-refresh when a cursor refresh fails on the first const { rerender } = render( ); - fireEvent.click(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); rerender( ); expect(screen.getByText("Task-wide failure")).toBeVisible(); expect(screen.getByRole("alert")).toHaveTextContent("Unable to load open issues"); - expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).toBeChecked(); + expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "true"); rerender( ); expect(screen.queryByText("Task-wide failure")).toBeNull(); - expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "false"); }); it("links queue entries by request ID even when the change URI is missing", () => { if (!model.history.ok) throw new Error("No summary"); diff --git a/web/stovepipe/view/refresh.test.tsx b/web/stovepipe/view/refresh.test.tsx index a4235c874..4c25d9a63 100644 --- a/web/stovepipe/view/refresh.test.tsx +++ b/web/stovepipe/view/refresh.test.tsx @@ -35,13 +35,13 @@ it("refreshes only after polling is enabled, then stops when disabled", async () ); await act(async () => vi.advanceTimersByTime(30_000)); expect(refresh).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); await act(async () => vi.advanceTimersByTime(9_999)); expect(refresh).not.toHaveBeenCalled(); await act(async () => vi.advanceTimersByTime(1)); expect(refresh).toHaveBeenCalledWith(null); expect(refresh).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); await act(async () => vi.advanceTimersByTime(30_000)); expect(refresh).toHaveBeenCalledTimes(1); }); @@ -56,10 +56,10 @@ it("reports a failed refresh and recovers on the next ten-second poll", async () , ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); await act(async () => vi.advanceTimersByTime(10_000)); expect(screen.getByRole("alert")).toHaveTextContent("Refresh failed"); - expect(screen.getByRole("checkbox")).toBeChecked(); + expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "true"); await act(async () => vi.advanceTimersByTime(10_000)); expect(refresh).toHaveBeenCalledTimes(2); expect(screen.queryByRole("alert")).toBeNull(); @@ -72,7 +72,7 @@ it("removes scheduled polls when the page unmounts", async () => { , ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); unmount(); await act(async () => vi.advanceTimersByTime(30_000)); expect(refresh).not.toHaveBeenCalled(); @@ -82,7 +82,7 @@ it("returns cursor-based issue pages to their first page on refresh", async () = render( ); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); await act(async () => vi.advanceTimersByTime(10_000)); expect(refresh).toHaveBeenCalledWith("/queue?view=issues"); }); diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md index e3e1cdb14..f5519706a 100644 --- a/web/submitqueue/README.md +++ b/web/submitqueue/README.md @@ -23,7 +23,7 @@ Instrumentation belongs in the host: wrap `handle` to observe page results and i **Behavior worth knowing.** Pages live under `/queue/` (`/request/` and `/change/…` below it), so queue names never collide with host routes. `queueRequests` returns the queue page's requests as data for a host's JSON route, with the same authorization and filters. A queue list starts at its newest request however old it is, with no time window in the URL. Repeated `?status=` parameters restrict it to those current statuses; the gateway applies the filter, so a filtered page can be short while older matches load as the viewer scrolls. Queue refreshes update the status of displayed requests in place; new arrivals appear behind a Show latest requests action. Older pages put the gateway's `List` continuation token in the URL as `?page=` unchanged; the token carries its own window, so continuing needs only the queue and token, and hosts hold no pagination secret. Expected outcomes (missing resources, page tokens the gateway rejects) are results, never exceptions. Raw gateway errors are logged, never rendered. Views keep the last good data on transient failures. Request IDs stay opaque in URLs; dot-only segments get a visible `~` escape. -The header offers optional ten-second auto-refresh, off by default. Browser reload remains available for manual updates. Polling pauses while the page is hidden or offline and stops for terminal views. +The header offers optional ten-second auto-refresh, off by default. The circular-arrow button toggles polling, stays selected while enabled, and animates only during a refresh; BaseUI tooltips identify Enable auto-refresh and Disable auto-refresh. Browser reload remains available for manual updates. Polling pauses while the page is hidden or offline and stops for terminal views. Timestamps default to the browser's time zone. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. diff --git a/web/submitqueue/view/pages.test.tsx b/web/submitqueue/view/pages.test.tsx index e79fb4da0..0a20d5e38 100644 --- a/web/submitqueue/view/pages.test.tsx +++ b/web/submitqueue/view/pages.test.tsx @@ -43,7 +43,10 @@ describe("default reusable application views", () => { const refresh = vi.fn(async () => undefined); render(); expect(screen.getByRole("heading", { name: heading })).toBeTruthy(); - expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "false"); + const appHeader = screen.getByRole("link", { name: "SubmitQueue queues" }).closest("header")!; + expect(within(appHeader).getByRole("button", { name: /auto-refresh/i })).toBeTruthy(); + expect(within(screen.getByRole("main")).queryByRole("button", { name: /auto-refresh/i })).toBeNull(); expect(refresh).not.toHaveBeenCalled(); const queueLinks = screen.queryAllByRole("link", { name: "demo-queue" }); queueLinks.forEach(link => expect(link.getAttribute("href")).toBe("/sq/queue/demo-queue")); diff --git a/web/submitqueue/view/web-app.test.tsx b/web/submitqueue/view/web-app.test.tsx index 092d94165..c82acbb32 100644 --- a/web/submitqueue/view/web-app.test.tsx +++ b/web/submitqueue/view/web-app.test.tsx @@ -34,7 +34,7 @@ describe("WebApp", () => { await act(async () => vi.advanceTimersByTime(30_000)); expect(refresh).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })); + fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i })); await act(async () => vi.advanceTimersByTime(10_000)); expect(refresh).toHaveBeenCalledWith("/latest"); @@ -61,7 +61,7 @@ describe("WebApp", () => { it("omits auto-refresh when the host cannot refresh in place", () => { renderApp(pageModel("First"), {}); - expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.queryByRole("button", { name: /auto-refresh/i })).toBeNull(); }); }); diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index fed24db72..17473a2fd 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -15,7 +15,7 @@ test.describe("server-rendered styling", () => { test("styles the queue before hydration", async ({ page }) => { await page.goto("/web%2Fpagination"); await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "32px"); - await expect(page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); + await expect(page.getByRole("banner").getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "false"); await expect(page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr")).toHaveCount(50); await expect(page.getByRole("status")).toContainText("More requests available"); }); @@ -134,17 +134,18 @@ test("keeps auto-refresh enabled when an issue cursor refresh returns to the fir await expect(region.getByRole("status")).toContainText("3 issues loaded"); const now = new Date(); await page.clock.install({ time: now }); - await page.clock.pauseAt(now); - const automatic = page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" }); - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + // Pause ahead of installation so the running clock cannot overtake the target. + await page.clock.pauseAt(new Date(now.getTime() + 60_000)); + const automatic = page.getByRole("banner").getByRole("button", { name: /auto-refresh/i }); + await automatic.click(); const response = page.waitForResponse(response => response.request().headers()["rsc"] === "1" && new URL(response.url()).pathname === "/web%2Fpagination"); await page.clock.runFor(10_000); expect((await response).ok()).toBe(true); await expect(region.getByRole("status")).toContainText("50 issues loaded"); - await expect(automatic).toBeChecked(); + await expect(automatic).toHaveAttribute("aria-pressed", "true"); expect(new URL(page.url()).searchParams.has("page")).toBe(false); - await expect(page.getByRole("banner").getByText("Live", { exact: true })).toBeVisible(); + await expect(page.getByRole("banner").getByRole("status")).toHaveText("Live"); }); test("keeps loaded issue pages across polls until the viewer selects refreshed results", async ({ page }) => { await page.goto("/web%2Fpagination?view=issues"); @@ -153,24 +154,25 @@ test("keeps loaded issue pages across polls until the viewer selects refreshed r await expect(region.getByRole("status")).toContainText("53 issues loaded"); const now = new Date(); await page.clock.install({ time: now }); - await page.clock.pauseAt(now); - const automatic = page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" }); - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + // Pause ahead of installation so the running clock cannot overtake the target. + await page.clock.pauseAt(new Date(now.getTime() + 60_000)); + const automatic = page.getByRole("banner").getByRole("button", { name: /auto-refresh/i }); + await automatic.click(); for (let cycle = 0; cycle < 2; cycle++) { const response = page.waitForResponse(response => response.request().headers()["rsc"] === "1" && new URL(response.url()).pathname === "/web%2Fpagination"); await page.clock.runFor(10_000); expect((await response).ok()).toBe(true); - await expect(page.getByRole("banner").getByText("Live", { exact: true })).toBeVisible(); + await expect(page.getByRole("banner").getByRole("status")).toHaveText("Live"); await expect(region.getByRole("status")).toContainText("53 issues loaded"); await expect(region.getByRole("heading", { name: "expensive", exact: true })).toBeVisible(); - await expect(automatic).toBeChecked(); + await expect(automatic).toHaveAttribute("aria-pressed", "true"); } await region.getByRole("button", { name: "Show latest issues", exact: true }).click(); await expect(region.getByRole("status")).toContainText("50 issues loaded"); await expect(region.getByRole("heading", { name: "expensive", exact: true })).toHaveCount(0); await expect(region.getByRole("button", { name: "Show latest issues", exact: true })).toHaveCount(0); - await expect(automatic).toBeChecked(); + await expect(automatic).toHaveAttribute("aria-pressed", "true"); }); test("keeps queue tables inside a narrow viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); @@ -281,8 +283,8 @@ test("challenges anonymous continuation requests", async () => { }); test("refreshes from the header only while auto-refresh is enabled", async ({ page }) => { await page.goto("/web%2Fpagination"); - const automatic = page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" }); - await expect(automatic).not.toBeChecked(); + const automatic = page.getByRole("banner").getByRole("button", { name: /auto-refresh/i }); + await expect(automatic).toHaveAttribute("aria-pressed", "false"); const now = new Date(); await page.clock.install({ time: now }); // The installed clock keeps running, so pausing at the install time races it. @@ -293,18 +295,18 @@ test("refreshes from the header only while auto-refresh is enabled", async ({ pa page.on("request", request => { if (isRefresh(request)) refreshRequests++; }); await page.clock.fastForward(30_000); expect(refreshRequests).toBe(0); - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + await automatic.click(); await page.clock.runFor(9_999); expect(refreshRequests).toBe(0); for (let cycle = 0; cycle < 2; cycle++) { const response = page.waitForResponse(response => isRefresh(response.request())); await page.clock.runFor(cycle === 0 ? 1 : 10_000); expect((await response).ok()).toBe(true); - await expect(page.getByRole("banner").getByText("Live", { exact: true })).toBeVisible(); - await expect(automatic).toBeChecked(); + await expect(page.getByRole("banner").getByRole("status")).toHaveText("Live"); + await expect(automatic).toHaveAttribute("aria-pressed", "true"); } - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); - await expect(automatic).not.toBeChecked(); + await automatic.click(); + await expect(automatic).toHaveAttribute("aria-pressed", "false"); await page.clock.fastForward(30_000); expect(refreshRequests).toBe(2); }); diff --git a/web/test/e2e/submitqueue/submitqueue.spec.ts b/web/test/e2e/submitqueue/submitqueue.spec.ts index 878079318..e5d92fb7c 100644 --- a/web/test/e2e/submitqueue/submitqueue.spec.ts +++ b/web/test/e2e/submitqueue/submitqueue.spec.ts @@ -322,8 +322,8 @@ test("paginates change history through more than 500 queue requests", async ({ p test("refreshes from the header only while auto-refresh is enabled", async ({ page }) => { await page.goto(`/queue/${queue}`); - const automatic = page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" }); - await expect(automatic).not.toBeChecked(); + const automatic = page.getByRole("banner").getByRole("button", { name: /auto-refresh/i }); + await expect(automatic).toHaveAttribute("aria-pressed", "false"); const now = new Date(); await page.clock.install({ time: now }); // The installed clock keeps running, so pausing at the install time races it. @@ -334,18 +334,18 @@ test("refreshes from the header only while auto-refresh is enabled", async ({ pa page.on("request", request => { if (isRefresh(request)) refreshRequests++; }); await page.clock.fastForward(30_000); expect(refreshRequests).toBe(0); - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + await automatic.click(); await page.clock.runFor(9_999); expect(refreshRequests).toBe(0); for (let cycle = 0; cycle < 2; cycle++) { const response = page.waitForResponse(response => isRefresh(response.request())); await page.clock.runFor(cycle === 0 ? 1 : 10_000); expect((await response).ok()).toBe(true); - await expect(page.getByRole("banner").getByText("Live", { exact: true })).toBeVisible(); - await expect(automatic).toBeChecked(); + await expect(page.getByRole("banner").getByRole("status")).toHaveText("Live"); + await expect(automatic).toHaveAttribute("aria-pressed", "true"); } - await page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); - await expect(automatic).not.toBeChecked(); + await automatic.click(); + await expect(automatic).toHaveAttribute("aria-pressed", "false"); await page.clock.fastForward(30_000); expect(refreshRequests).toBe(2); }); From c838b3482ec56dccc0b488e5ddc73285fcf6955d Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Tue, 10 Nov 2026 19:31:32 +0000 Subject: [PATCH 2/2] fix(web): Wait for request navigation in E2E --- web/test/e2e/submitqueue/submitqueue.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/test/e2e/submitqueue/submitqueue.spec.ts b/web/test/e2e/submitqueue/submitqueue.spec.ts index e5d92fb7c..c0e86eea1 100644 --- a/web/test/e2e/submitqueue/submitqueue.spec.ts +++ b/web/test/e2e/submitqueue/submitqueue.spec.ts @@ -370,7 +370,11 @@ test.describe("time zone selection", () => { await timeZones.getByRole("radio", { name: "UTC", exact: true }).check(); await expectTimeZone("UTC"); await closeTimeZones(); - await page.locator(".sq-request-list__item td:first-child a").first().click(); + const requestLink = page.locator(".sq-request-list__item td:first-child a").first(); + const requestId = await requestLink.innerText(); + await requestLink.click(); + await expect(page).toHaveURL(`${webURL}/queue/${queue}/request/${requestId}`); + await expect(page.getByRole("heading", { name: requestId, exact: true })).toBeVisible(); await openTimeZones(); await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC");