From 4c2d6f4b86758d58f4255c8bdd4fca8a3cd43d86 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Fri, 23 Oct 2026 20:34:35 +0000 Subject: [PATCH] feat(web): add header time zone and auto-refresh controls Summary: Let viewers choose browser time or UTC and opt into automatic updates from the shared header. Remember the time-zone preference, default auto-refresh off, remove the manual Refresh button, and align both controls using BaseUI typography and layout. Test Plan: Run the reference hosts with make local-submitqueue-start or make local-stovepipe-start. Verified time-zone switching and persistence, opt-in ten-second polling, and matching typography and alignment at desktop and mobile widths. --- doc/rfc/web-ui.md | 6 +- web/platform/BUILD.bazel | 1 + web/platform/auto-refresh.test.tsx | 21 +++-- web/platform/auto-refresh.tsx | 36 +++++---- web/platform/timestamp.test.tsx | 54 +++++++++++++ web/platform/timestamp.tsx | 59 +++++++++++++- .../src/app/(protected)/[[...path]]/page.tsx | 1 + .../src/app/(protected)/layout.tsx | 6 +- web/service/submitqueue/src/next/page.tsx | 5 +- web/stovepipe/README.md | 2 +- web/stovepipe/view/app.tsx | 11 ++- web/stovepipe/view/refresh.test.tsx | 16 ++-- web/stovepipe/view/refresh.tsx | 13 +-- web/submitqueue/README.md | 6 +- web/submitqueue/view/app.tsx | 14 +++- web/submitqueue/view/components.test.tsx | 2 +- web/submitqueue/view/components.tsx | 10 +-- web/submitqueue/view/pages.test.tsx | 10 +-- web/submitqueue/view/pages.tsx | 9 +-- web/submitqueue/view/refresh-control.tsx | 4 +- web/submitqueue/view/shell.tsx | 14 +++- web/submitqueue/view/web-app.test.tsx | 19 +++-- web/submitqueue/view/web-app.tsx | 12 +-- web/test/e2e/stovepipe/stovepipe.spec.ts | 80 +++++++++++++++++-- web/test/e2e/submitqueue/submitqueue.spec.ts | 77 ++++++++++++++++-- web/test/package/consumer-runtime.test.mjs | 5 +- .../package/react18/react18-runtime.test.mjs | 12 +-- 27 files changed, 387 insertions(+), 118 deletions(-) create mode 100644 web/platform/timestamp.test.tsx diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md index aab9de2e5..4bd98baf6 100644 --- a/doc/rfc/web-ui.md +++ b/doc/rfc/web-ui.md @@ -62,9 +62,11 @@ Summary is the default request view, and `?view=history` makes History shareable ## Behavior -**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Refreshing an older page returns to the live first page. +**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Browser reload retains an older page; its latest link or auto-refresh returns to the live first page. -**Refresh and polling.** 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. 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. +**Time zones.** Timestamps default to the browser time zone. The header's Browser/UTC segmented control 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. **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/BUILD.bazel b/web/platform/BUILD.bazel index b7d0439e8..7b834244f 100644 --- a/web/platform/BUILD.bazel +++ b/web/platform/BUILD.bazel @@ -35,6 +35,7 @@ web_library( ":node_modules/@types/react", ":node_modules/baseui", ":node_modules/react", + "//web:node_modules/@types/react", ], ) diff --git a/web/platform/auto-refresh.test.tsx b/web/platform/auto-refresh.test.tsx index c46fe29e0..abf358296 100644 --- a/web/platform/auto-refresh.test.tsx +++ b/web/platform/auto-refresh.test.tsx @@ -15,6 +15,7 @@ describe("AutoRefresh", () => { 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")); await act(async () => vi.advanceTimersByTime(2_249)); expect(refresh).not.toHaveBeenCalled(); @@ -24,13 +25,13 @@ describe("AutoRefresh", () => { expect(refresh).toHaveBeenCalledTimes(2); }); - it("keeps manual and timer refreshes single-flight beyond multiple intervals", async () => { + it("keeps polling single-flight beyond multiple intervals", async () => { let finish: (() => void) | undefined; const refresh = vi.fn(() => new Promise((resolve) => (finish = resolve))); render(); + fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(100)); - fireEvent.click(screen.getByRole("button", { name: "Refreshing…" })); await act(async () => vi.advanceTimersByTime(500)); expect(refresh).toHaveBeenCalledTimes(1); await act(async () => finish?.()); @@ -48,6 +49,7 @@ describe("AutoRefresh", () => { transientFailureCount={4} />, ); + fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(29_999)); expect(refresh).not.toHaveBeenCalled(); @@ -67,6 +69,7 @@ describe("AutoRefresh", () => { transientFailureCount={1} />, ); + fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(200)); expect(refresh).toHaveBeenCalledTimes(1); @@ -83,6 +86,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")); await act(async () => vi.advanceTimersByTime(100)); expect(refresh).toHaveBeenCalledTimes(1); @@ -100,6 +104,7 @@ describe("AutoRefresh", () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); const refresh = vi.fn(async () => undefined); render(); + fireEvent.click(screen.getByRole("checkbox")); expect(screen.getByText("Automatic refresh paused")).toBeTruthy(); await act(async () => vi.advanceTimersByTime(1_000)); @@ -114,6 +119,7 @@ describe("AutoRefresh", () => { Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); const refresh = vi.fn(async () => undefined); render(); + fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(1_000)); expect(refresh).not.toHaveBeenCalled(); @@ -125,13 +131,15 @@ describe("AutoRefresh", () => { it("stops automatic polling for terminal detail views", async () => { const refresh = vi.fn(async () => undefined); - render(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("checkbox")); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + rerender(); await act(async () => vi.advanceTimersByTime(1_000)); - expect(refresh).not.toHaveBeenCalled(); - expect(screen.getByText("Automatic refresh stopped")).toBeTruthy(); - await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" }))); expect(refresh).toHaveBeenCalledTimes(1); + expect(screen.getByRole("checkbox")).toBeDisabled(); }); it("does not poll a terminal list failure", async () => { @@ -145,6 +153,7 @@ describe("AutoRefresh", () => { transientFailureCount={1} />, ); + fireEvent.click(screen.getByRole("checkbox")); 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 028652eac..4e681dd45 100644 --- a/web/platform/auto-refresh.tsx +++ b/web/platform/auto-refresh.tsx @@ -1,14 +1,15 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { Theme } from "baseui"; import { Block } from "baseui/block"; -import { Button, SIZE } from "baseui/button"; +import { Checkbox } from "baseui/checkbox"; import { LabelXSmall } from "baseui/typography"; export interface AutoRefreshProps { terminal?: boolean; - /** Whether to poll; when false only the manual button refreshes. */ - automatic?: boolean; + /** Whether the host supports refreshing in place. */ + available?: boolean; intervalMs?: number; jitterMs?: number; maxBackoffMs?: number; @@ -23,14 +24,15 @@ function pageCanRefresh(): boolean { export function AutoRefresh({ terminal = false, - automatic = true, - intervalMs = 2_000, - jitterMs = 500, + available = true, + intervalMs = 10_000, + jitterMs = 0, maxBackoffMs = 30_000, transientFailureCount = 0, refresh, random = Math.random, }: AutoRefreshProps) { + const [enabled, setEnabled] = useState(false); const [refreshing, setRefreshing] = useState(false); const [paused, setPaused] = useState(false); const [cycle, setCycle] = useState(0); @@ -80,7 +82,7 @@ export function AutoRefresh({ }, []); useEffect(() => { - if (terminal || paused || !automatic) { + if (terminal || paused || !available || !enabled) { return; } const failures = Math.max(transientFailureCount, localFailures.current); @@ -96,17 +98,23 @@ export function AutoRefresh({ timer.current = null; } }; - }, [automatic, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]); + }, [available, enabled, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]); + + if (!available) return null; return ( - - - {terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" : + 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 && + {terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : refreshing ? "Refreshing…" : transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"} - + } ); } diff --git a/web/platform/timestamp.test.tsx b/web/platform/timestamp.test.tsx new file mode 100644 index 000000000..37d241f55 --- /dev/null +++ b/web/platform/timestamp.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, fireEvent, render, screen } from "./test/rtl.js"; +import { afterEach, expect, it, vi } from "vitest"; +import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "./timestamp.js"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +it("defaults to the browser zone, respects daylight saving, and switches every timestamp without changing its instant", () => { + const options = new Intl.DateTimeFormat().resolvedOptions(); + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ ...options, timeZone: "America/New_York" }); + const instants = ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"]; + const { container } = render( + + {instants.map(instant => )} + ); + const browser = screen.getByRole("option", { name: "Browser" }); + expect(browser).toHaveAttribute("aria-selected", "true"); + expect(browser).toHaveAttribute("title", "America/New_York"); + expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EDT")).toBeVisible(); + expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible(); + fireEvent.click(screen.getByRole("option", { name: "UTC" })); + expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toBeVisible(); + expect(screen.getByText("Nov 1, 2026, 6:30:00 AM UTC")).toBeVisible(); + expect([...container.querySelectorAll("time")].map(time => time.dateTime)).toEqual(instants); + fireEvent.click(browser); + expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible(); +}); + +it("preserves invalid timestamp values without inventing a date", () => { + render(); + expect(screen.getByText("unknown")).not.toHaveAttribute("datetime"); +}); + +it("remembers UTC when a new page mounts", () => { + const page = ; + const first = render(page); + fireEvent.click(screen.getByRole("option", { name: "UTC" })); + first.unmount(); + render(page); + expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Jan 1, 1970, 12:00:00 AM UTC")).toBeVisible(); +}); + +it("allows switching when browser storage is unavailable", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); }); + render(); + fireEvent.click(screen.getByRole("option", { name: "UTC" })); + expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true"); +}); diff --git a/web/platform/timestamp.tsx b/web/platform/timestamp.tsx index e26c65d6e..9706363cb 100644 --- a/web/platform/timestamp.tsx +++ b/web/platform/timestamp.tsx @@ -1,11 +1,66 @@ "use client"; -const timestampFormatter = new Intl.DateTimeFormat("en-US", { +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Block } from "baseui/block"; +import { Segment, SegmentedControl } from "baseui/segmented-control"; +import { LabelSmall } from "baseui/typography"; + +const utcFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone: "UTC", }); +const preferenceKey = "submitqueue.time-zone"; + +const TimeZoneContext = createContext<{ + selection: "browser" | "utc"; + selectTimeZone(selection: "browser" | "utc"): void; + browserTimeZone: string; + formatter: Intl.DateTimeFormat; +} | null>(null); + +/** Remembers the browser's selection across page loads; UTC is the server-rendering fallback. */ +export function TimeZoneProvider({ children }: { children: ReactNode }) { + const [selection, setSelection] = useState<"browser" | "utc">("browser"); + const [browserTimeZone, setBrowserTimeZone] = useState("UTC"); + useEffect(() => { + setBrowserTimeZone(new Intl.DateTimeFormat().resolvedOptions().timeZone); + try { + if (window.localStorage.getItem(preferenceKey) === "utc") setSelection("utc"); + } catch { + // Storage may be unavailable; the toggle still works for this page. + } + }, []); + const selectTimeZone = useCallback((next: "browser" | "utc") => { + setSelection(next); + try { + window.localStorage.setItem(preferenceKey, next); + } catch { + // Storage may be unavailable; keep the in-memory selection. + } + }, []); + const formatter = useMemo(() => new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", timeStyle: "long", timeZone: selection === "utc" ? "UTC" : browserTimeZone, + }), [selection, browserTimeZone]); + const value = useMemo(() => ({ selection, selectTimeZone, browserTimeZone, formatter }), [selection, selectTimeZone, browserTimeZone, formatter]); + return {children}; +} + +export function TimeZoneToggle() { + const context = useContext(TimeZoneContext); + if (!context) return null; + return + Time zone + context.selectTimeZone(activeKey === "utc" ? "utc" : "browser")} + overrides={{ SegmentList: { props: { "aria-label": "Time zone" } } }}> + + + + ; +} export function Timestamp({ value }: { value: string | number }) { + const formatter = useContext(TimeZoneContext)?.formatter ?? utcFormatter; const date = new Date(Number(value)); return Number.isNaN(date.getTime()) ? : - ; + ; } diff --git a/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx b/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx index ac1612ce4..29748b978 100644 --- a/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx +++ b/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx @@ -7,6 +7,7 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; const page = createNextPage({ + shell: { environmentLabel: "Local demo · read-only", footer: "Read-only demo" }, web: resolveReferenceWeb, authenticate: requireAuthorization, onLog: resolveReferenceLogCallback(), diff --git a/web/service/submitqueue/src/app/(protected)/layout.tsx b/web/service/submitqueue/src/app/(protected)/layout.tsx index 279e62935..3594dbed0 100644 --- a/web/service/submitqueue/src/app/(protected)/layout.tsx +++ b/web/service/submitqueue/src/app/(protected)/layout.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from "react"; -import { SubmitQueueShell } from "@submitqueue/web-submitqueue"; import { requireAuthorization } from "../../next/authorize"; @@ -11,8 +10,5 @@ export default async function ProtectedLayout({ }: Readonly<{ children: ReactNode }>) { await requireAuthorization(); - return ( - {children} - ); + return children; } diff --git a/web/service/submitqueue/src/next/page.tsx b/web/service/submitqueue/src/next/page.tsx index 6e4197374..faf0b057c 100644 --- a/web/service/submitqueue/src/next/page.tsx +++ b/web/service/submitqueue/src/next/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { forbidden, notFound, redirect } from "next/navigation"; import { connection } from "next/server"; import { cache } from "react"; -import { SubmitQueueApp } from "@submitqueue/web-submitqueue"; +import { SubmitQueueApp, type SubmitQueueShellProps } from "@submitqueue/web-submitqueue"; import type { SubmitQueueWeb, SubmitQueueWebRequest, WebSearchParams } from "@submitqueue/web-submitqueue/server"; import { NextNavigationProvider } from "@submitqueue/web-service-shared/navigation"; @@ -12,6 +12,7 @@ export interface NextPageProps { } export interface NextPageOptions { + shell?: Omit; /** The web UI, or a resolver called per request so configuration is read at runtime rather than build time. */ web: SubmitQueueWeb | (() => SubmitQueueWeb); /** @@ -61,7 +62,7 @@ export function createNextPage(options: NextPageOptions) { case "redirect": redirect(result.href); case "render": - return ; + return ; } } diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 326e74e03..04a61752b 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -35,6 +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; request-detail results are scoped to their route. Timestamps use UTC. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. +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, with a Browser/UTC segmented control in the header. 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.tsx b/web/stovepipe/view/app.tsx index f297dad3c..a72b1bf04 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fact, Timestamp } from "@submitqueue/web-platform"; +import { Fact, Timestamp, TimeZoneProvider, TimeZoneToggle } from "@submitqueue/web-platform"; import { Block } from "baseui/block"; import { Tag } from "baseui/tag"; import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography"; @@ -16,10 +16,14 @@ function Page({ model }: { model: StovepipePageModel }) { - + SPStovepipe + + + @@ -51,7 +55,6 @@ function Page({ model }: { model: StovepipePageModel }) { : model.queue} - {model.kind === "queue" && model.showLatestLink && ( @@ -101,5 +104,5 @@ function Page({ model }: { model: StovepipePageModel }) { ); } export function StovepipeApp({ model }: { model: StovepipePageModel }) { - return ; + return ; } diff --git a/web/stovepipe/view/refresh.test.tsx b/web/stovepipe/view/refresh.test.tsx index f053c4fa2..5101866a4 100644 --- a/web/stovepipe/view/refresh.test.tsx +++ b/web/stovepipe/view/refresh.test.tsx @@ -26,7 +26,7 @@ afterEach(() => { vi.useRealTimers(); }); -it("refreshes manually until polling is enabled, then stops when disabled", async () => { +it("refreshes only after polling is enabled, then stops when disabled", async () => { const refresh = vi.fn(async () => undefined); render( @@ -35,19 +35,15 @@ it("refreshes manually until polling is enabled, then stops when disabled", asyn ); await act(async () => vi.advanceTimersByTime(30_000)); expect(refresh).not.toHaveBeenCalled(); - await act(async () => - fireEvent.click(screen.getByRole("button", { name: "Refresh" })), - ); - expect(refresh).toHaveBeenCalledWith(null); - fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(9_999)); - expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh).not.toHaveBeenCalled(); await act(async () => vi.advanceTimersByTime(1)); - expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledWith(null); + expect(refresh).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(30_000)); - expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); }); it("reports a failed refresh and recovers on the next ten-second poll", async () => { @@ -63,7 +59,7 @@ it("reports a failed refresh and recovers on the next ten-second poll", async () fireEvent.click(screen.getByRole("checkbox")); await act(async () => vi.advanceTimersByTime(10_000)); expect(screen.getByRole("alert")).toHaveTextContent("Refresh failed"); - expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(); + expect(screen.getByRole("checkbox")).toBeChecked(); await act(async () => vi.advanceTimersByTime(10_000)); expect(refresh).toHaveBeenCalledTimes(2); expect(screen.queryByRole("alert")).toBeNull(); diff --git a/web/stovepipe/view/refresh.tsx b/web/stovepipe/view/refresh.tsx index a5949e096..5e8e137c8 100644 --- a/web/stovepipe/view/refresh.tsx +++ b/web/stovepipe/view/refresh.tsx @@ -2,17 +2,14 @@ import { useCallback, useState } from "react"; import { AutoRefresh, useWebNavigation } from "@submitqueue/web-platform"; -import { Checkbox } from "baseui/checkbox"; import { ParagraphXSmall } from "baseui/typography"; export function RefreshControls() { const navigation = useWebNavigation(); - const [automatic, setAutomatic] = useState(false); const [error, setError] = useState(""); const refresh = useCallback(async () => { setError(""); if (!navigation.refresh) { - window.location.reload(); return; } try { @@ -26,19 +23,11 @@ export function RefreshControls() {
- {navigation.refresh && ( - setAutomatic(event.currentTarget.checked)} - > - Auto refresh every 10s - - )} {error && {error}}
); diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md index 5a8264e68..e436d9cd9 100644 --- a/web/submitqueue/README.md +++ b/web/submitqueue/README.md @@ -15,7 +15,7 @@ Entry points: the root is client-safe (`SubmitQueueApp`, page frames, `WebNaviga Process the UI entry with the host's bundler for both SSR and the browser. The `/server` entry runs directly in Node and does not load BaseUI. See the shared [package contract](../README.md#layers). -**Hosting it.** Give `createSubmitQueueWeb` a gateway client (any client matching the structural contract, so the module ships no generated code) and optionally a mount path, authorization check, error classifier, or cached queue catalog. Route every page path to `handle`, render `SubmitQueueApp` with each page model, and wrap it in a `WebNavigationProvider` for client-side navigation. Load `./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; BaseUI, React, React DOM, and Styletron React are peers. `WebShell` follows the provider's theme rather than taking a separate theme prop. +**Hosting it.** Give `createSubmitQueueWeb` a gateway client (any client matching the structural contract, so the module ships no generated code) and optionally a mount path, authorization check, error classifier, or cached queue catalog. Route every page path to `handle`, render `SubmitQueueApp` with each page model (it includes the shared header, time zone preference and refresh controls), and wrap it in a `WebNavigationProvider` for client-side navigation. Load `./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; BaseUI, React, React DOM, and Styletron React are peers. `WebShell` follows the provider's theme rather than taking a separate theme prop. Pass `onLog` on each `handle` request to deliver severity, message, and structured metadata to the host's request-scoped logger. Errors retain their original value under `error`, and logs include the correlation `requestId` and page `path`. The callback must be synchronous; omitting it is silent, and callback exceptions do not change page results. The module has no logging-library dependency. @@ -23,6 +23,10 @@ Instrumentation belongs in the host: wrap `handle` to observe page results and i **Behavior worth knowing.** 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. + +Timestamps default to the browser's time zone. The Browser/UTC segmented control in the shared header applies to 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. + ```bash ./tool/bazel test //web/submitqueue:all ``` diff --git a/web/submitqueue/view/app.tsx b/web/submitqueue/view/app.tsx index 5070c4c7f..cb073012d 100644 --- a/web/submitqueue/view/app.tsx +++ b/web/submitqueue/view/app.tsx @@ -2,9 +2,17 @@ import { WebApp } from "./web-app.js"; import type { SubmitQueuePageModel } from "../entity/index.js"; -import { SubmitQueuePage } from "./pages.js"; +import { SubmitQueuePage, SubmitQueueShell, type SubmitQueueShellProps } from "./pages.js"; +import { WebRefreshControl } from "./refresh-control.js"; /** The complete client experience for a loaded page: navigation, refresh, and polling through the host's `WebNavigationProvider`. */ -export function SubmitQueueApp({ model }: { model: SubmitQueuePageModel }) { - return ; +export function SubmitQueueApp({ model, shell }: { + model: SubmitQueuePageModel; + shell?: Omit; +}) { + return + }> + + + ; } diff --git a/web/submitqueue/view/components.test.tsx b/web/submitqueue/view/components.test.tsx index ac076cea9..c90cc2486 100644 --- a/web/submitqueue/view/components.test.tsx +++ b/web/submitqueue/view/components.test.tsx @@ -170,7 +170,7 @@ describe("request components", () => { }; const page = { key: "change", title: "PR #123", refresh: { terminal: true, transientFailureCount: 0, refreshHref: null } }; render( - } /> + ); fireEvent.click(screen.getByRole("combobox")); fireEvent.click(screen.getByRole("option", { name: version })); diff --git a/web/submitqueue/view/components.tsx b/web/submitqueue/view/components.tsx index e82a0a397..e54826f72 100644 --- a/web/submitqueue/view/components.tsx +++ b/web/submitqueue/view/components.tsx @@ -104,11 +104,11 @@ export function RequestList({ model, basePath, empty, changeLinks, changeLabels return
Find in displayed requests setSearch(event.currentTarget.value)} /> - Displayed page · UTC + Displayed page {model.requests.length === 0 ? {empty ?? "No requests were received in this window."} : requests.length === 0 ? No displayed requests match. : - +
{requests.map(request => {request.sqid} {request.lastError ? {request.lastError} : null} @@ -152,7 +152,7 @@ export function RequestHistory({ events }: { events: HistoryEventModel[] }) { {item.lastError ? {item.lastError} : null} )}} - {shown.length} displayed events · oldest first · UTC + {shown.length} displayed events · oldest first ; } @@ -225,7 +225,7 @@ export function RequestDetail({ model, view = "summary", basePath, backHref, sum {view === "history" ? model.historyError ? : : {request.queue}{request.sqid} - + {model.historyError ? "History unavailable" : latest ? <> : "No retained events"} {request.lastError ?? "None reported"}
Changes @@ -280,7 +280,7 @@ export function ChangeSubmissions({ model, basePath }: ChangeSubmissionsProps) { {model.window ? Receipt window: — . This is not unlimited retained history. : Retained submissions for this exact version} {model.submissions.length === 0 ? {model.pagination ? "No submissions were found on this page." : "No submissions were found in this scope."} : -
+
{model.submissions.map(item => {item.request.sqid} {item.version.length > 20 ? `${item.version.slice(0, 8)}…${item.version.slice(-4)}` : item.version} {item.request.lastError ? {item.request.lastError} : null})}
} diff --git a/web/submitqueue/view/pages.test.tsx b/web/submitqueue/view/pages.test.tsx index d3da68eb1..d3b7fcdb3 100644 --- a/web/submitqueue/view/pages.test.tsx +++ b/web/submitqueue/view/pages.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "../../platform/test/rtl.js"; +import { fireEvent, render, screen } from "../../platform/test/rtl.js"; import { describe, expect, it, vi } from "vitest"; import { WebNavigationProvider } from "@submitqueue/web-platform"; import { SubmitQueueApp } from "./app.js"; @@ -41,12 +41,10 @@ describe("default reusable application views", () => { throw new Error("Expected a rendered page"); } const refresh = vi.fn(async () => undefined); - render( - - ); + render(); expect(screen.getByRole("heading", { name: heading })).toBeTruthy(); - await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" }))); - expect(refresh).toHaveBeenCalledOnce(); + expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); + expect(refresh).not.toHaveBeenCalled(); const queueLinks = screen.queryAllByRole("link", { name: "demo-queue" }); queueLinks.forEach(link => expect(link.getAttribute("href")).toBe("/sq/demo-queue")); }); diff --git a/web/submitqueue/view/pages.tsx b/web/submitqueue/view/pages.tsx index 2cfcb224d..fe263ed07 100644 --- a/web/submitqueue/view/pages.tsx +++ b/web/submitqueue/view/pages.tsx @@ -1,6 +1,5 @@ "use client"; -import { WebRefreshControl } from "./refresh-control.js"; import { WebShell, type WebShellProps } from "./shell.js"; import { Timestamp } from "@submitqueue/web-platform"; import { Block } from "baseui/block"; @@ -19,9 +18,8 @@ export interface SubmitQueuePageProps { model: SubmitQueuePageModel; } -/** Renders one page model; refresh and version controls appear only inside a `WebApp` such as `SubmitQueueApp`. */ +/** Renders one page model; version controls appear only inside a `WebApp` such as `SubmitQueueApp`. */ export function SubmitQueuePage({ model }: SubmitQueuePageProps) { - const refreshControl = ; return {model.kind === "queue" ? <> @@ -29,7 +27,7 @@ export function SubmitQueuePage({ model }: SubmitQueuePageProps) { Queue activity{model.queue} {model.paged ? "Older requests · snapshot" : "Last 24 hours · updates on refresh"}: — - Latest 24 hours{refreshControl} + Latest 24 hours
: model.kind === "request" ? + changeLinks={model.changeLinks} changeLabels={model.changeLabels} /> : <> {model.kind === "queues" ? {queues => } : {change => }} - {refreshControl} } ; } diff --git a/web/submitqueue/view/refresh-control.tsx b/web/submitqueue/view/refresh-control.tsx index f665abab0..361f1d2a2 100644 --- a/web/submitqueue/view/refresh-control.tsx +++ b/web/submitqueue/view/refresh-control.tsx @@ -4,12 +4,12 @@ import type { WebRefreshModel } from "../entity/index.js"; import { AutoRefresh } from "@submitqueue/web-platform"; import { useWebApp } from "./web-app.js"; -/** Refresh button and polling status for the page rendered by the enclosing `WebApp`; renders nothing outside one. */ +/** Opt-in polling for the page rendered by the enclosing `WebApp`; renders nothing outside one. */ export function WebRefreshControl({ refresh }: { refresh: WebRefreshModel }) { const app = useWebApp(); if (!app) { return null; } return ; + available={app.automaticRefresh} refresh={app.refresh} />; } diff --git a/web/submitqueue/view/shell.tsx b/web/submitqueue/view/shell.tsx index 1626763b5..1b925fe75 100644 --- a/web/submitqueue/view/shell.tsx +++ b/web/submitqueue/view/shell.tsx @@ -1,6 +1,7 @@ "use client"; import type { ReactNode } from "react"; +import { TimeZoneProvider, TimeZoneToggle } from "@submitqueue/web-platform"; import { Block } from "baseui/block"; import { StyledLink } from "baseui/link"; import { Tag } from "baseui/tag"; @@ -16,13 +17,14 @@ export interface WebShellProps { brandMark?: string; environmentLabel?: string; footer?: ReactNode; + controls?: ReactNode; } export function WebShell({ children, homeHref = "/", homeLabel, brand, brandMark = "", environmentLabel = "Read-only", - footer = "Read-only · updates automatically", + footer = "Read-only", controls, }: WebShellProps) { - return {brandMark ? : null}{brand} - {environmentLabel ? {environmentLabel} : null} + + {environmentLabel ? {environmentLabel} : null} + + {controls} + {children} {footer ? {footer} : null} - ; + ; } diff --git a/web/submitqueue/view/web-app.test.tsx b/web/submitqueue/view/web-app.test.tsx index e9739e223..092d94165 100644 --- a/web/submitqueue/view/web-app.test.tsx +++ b/web/submitqueue/view/web-app.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from "../../platform/test/rtl.js"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { WebPageModel } from "../entity/index.js"; import { WebNavigationProvider, type WebNavigation } from "@submitqueue/web-platform"; @@ -21,15 +21,21 @@ function Page({ model }: { model: WebPageModel }) { } function renderApp(model: WebPageModel, navigation: WebNavigation) { - return render(); + return render(); } +afterEach(() => vi.useRealTimers()); + describe("WebApp", () => { it("refreshes through the host, moving a snapshot page to its refresh target", async () => { + vi.useFakeTimers(); const refresh = vi.fn(async () => undefined); renderApp(pageModel("Snapshot", "/latest"), { refresh }); - await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" }))); + await act(async () => vi.advanceTimersByTime(30_000)); + expect(refresh).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })); + await act(async () => vi.advanceTimersByTime(10_000)); expect(refresh).toHaveBeenCalledWith("/latest"); }); @@ -47,14 +53,15 @@ describe("WebApp", () => { const navigation = {}; const { rerender } = renderApp(pageModel("First"), navigation); - rerender(); + const second = pageModel("Second"); + rerender(); expect(screen.getByRole("heading").textContent).toBe("Second"); }); - it("offers only manual refresh when the host cannot refresh in place", () => { + it("omits auto-refresh when the host cannot refresh in place", () => { renderApp(pageModel("First"), {}); - expect(screen.getByText("Manual refresh")).toBeDefined(); + expect(screen.queryByRole("checkbox")).toBeNull(); }); }); diff --git a/web/submitqueue/view/web-app.tsx b/web/submitqueue/view/web-app.tsx index 3ec57a645..c2ac7a01f 100644 --- a/web/submitqueue/view/web-app.tsx +++ b/web/submitqueue/view/web-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useMemo, useRef, type ComponentType } from "react"; +import { createContext, useContext, useMemo, useRef, type ReactNode } from "react"; import type { WebPageModel } from "../entity/index.js"; import { useWebNavigation, type WebLinkProps } from "@submitqueue/web-platform"; import { StyledLink } from "baseui/link"; @@ -21,9 +21,9 @@ export function useWebApp(): WebAppControls | null { return useContext(AppContext); } -export interface WebAppProps { - model: Model; - Page: ComponentType<{ model: Model }>; +export interface WebAppProps { + model: WebPageModel; + children: ReactNode; } /** @@ -31,7 +31,7 @@ export interface WebAppProps { * and refresh through the host's `WebNavigationProvider`. The host owns the * model: a refresh or navigation re-renders `WebApp` with a new one. */ -export function WebApp({ model, Page }: WebAppProps) { +export function WebApp({ model, children }: WebAppProps) { const navigation = useWebNavigation(); const modelRef = useRef(model); modelRef.current = model; @@ -55,7 +55,7 @@ export function WebApp({ model, Page }: WebAppProps< }, }), [navigation]); - return ; + return {children}; } /** An in-app link styled by BaseUI, using the host's router when supplied. */ diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index f878fc33a..10fcb206e 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("button", { name: "Refresh", exact: true })).toHaveCSS("background-color", "rgb(0, 0, 0)"); + await expect(page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); await expect(page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr")).toHaveCount(50); await expect(page.getByRole("status")).toContainText("More requests available"); }); @@ -158,6 +158,77 @@ test("challenges anonymous continuation requests", async () => { expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toContain("Basic"); }); +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 now = new Date(); + await page.clock.install({ time: now }); + await page.clock.pauseAt(now); + let refreshRequests = 0; + const isRefresh = (request: import("@playwright/test").Request) => + request.headers()["rsc"] === "1" && new URL(request.url()).pathname === new URL(page.url()).pathname; + 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 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 page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + await expect(automatic).not.toBeChecked(); + await page.clock.fastForward(30_000); + expect(refreshRequests).toBe(2); +}); + +test.describe("time zone selection", () => { + test.use({ timezoneId: "America/Los_Angeles" }); + test("defaults to browser time, switches all timestamps, and retains UTC across navigation", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("console", message => { + if (message.type() === "error" && /hydrat|did not match|Minified React error #(?:418|423|425)/iu.test(message.text())) errors.push(message.text()); + }); + const expectTimeZone = async (zone: string) => { + await expect.poll(() => page.locator("time[datetime]").evaluateAll((times, timeZone) => { + const formatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone }); + return times.length > 0 && times.every(time => time.textContent === formatter.format(new Date(time.getAttribute("datetime")!))); + }, zone)).toBe(true); + }; + const timeZones = page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true }); + await page.goto("/web%2Fpagination"); + await expect(timeZones.getByRole("option", { name: "Browser", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("America/Los_Angeles"); + await timeZones.getByRole("option", { name: "UTC", exact: true }).click(); + await expectTimeZone("UTC"); + await page.getByRole("link", { name: "51", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Request 51", exact: true })).toBeVisible(); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + await timeZones.getByRole("option", { name: "Browser", exact: true }).click(); + await expectTimeZone("America/Los_Angeles"); + const browser = timeZones.getByRole("option", { name: "Browser", exact: true }); + await browser.focus(); + await page.keyboard.press("ArrowRight"); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.reload(); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + await browser.click(); + await page.reload(); + await expect(browser).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("America/Los_Angeles"); + expect(errors).toEqual([]); + }); +}); test("real ingestion, queue selection, cursors, request summaries, history, project results and refresh", async ({ page, }) => { @@ -182,7 +253,6 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj }); const requests = page.getByRole("table", { name: "Requests", exact: true }); const queues = page.getByRole("navigation", { name: "Queues" }); - const refresh = page.getByRole("button", { name: "Refresh", exact: true }); await test.step("Open the ingested request, read history and refresh", async () => { await page.goto("/monorepo%2Fmain"); @@ -205,8 +275,7 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj await expect( page.getByRole("region", { name: "Request status" }), ).toBeVisible(); - await refresh.click(); - await expect(refresh).toBeEnabled(); + await page.reload(); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); await test.step("Select the populated queue", async () => { @@ -280,8 +349,7 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj has: page.getByRole("cell", { name: "project-02", exact: true }), }).getByRole("cell", { name: "Green", exact: true }), ).toBeVisible(); - await refresh.click(); - await expect(refresh).toBeEnabled(); + await page.reload(); await expect(projects.locator("tbody tr")).toHaveCount(50); await section.getByRole("link", { name: "Next projects →", exact: true }).click(); await expect(projects.locator("tbody tr")).toHaveCount(1); diff --git a/web/test/e2e/submitqueue/submitqueue.spec.ts b/web/test/e2e/submitqueue/submitqueue.spec.ts index c0d3e3d49..99796b481 100644 --- a/web/test/e2e/submitqueue/submitqueue.spec.ts +++ b/web/test/e2e/submitqueue/submitqueue.spec.ts @@ -89,7 +89,7 @@ test("shows newest requests and an accessible readable detail history", async ({ "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", ); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.reload(); const requestLinks = page.locator(".sq-request-list__item td:first-child a"); await expect(requestLinks).toHaveCount(3, { timeout: 0 }); await expect(requestLinks.nth(0)).toHaveText(repeatedSqid); @@ -121,11 +121,11 @@ test("shows newest requests and an accessible readable detail history", async ({ await expect(page.getByRole("button", { name: "Copy ID" })).toBeVisible(); await expect(page.getByRole("link", { name: "Summary", exact: true })).toHaveAttribute("aria-current", "page"); - const actionBoxes = await Promise.all(["Copy ID", "Copy link", "Refresh"].map(name => + const actionBoxes = await Promise.all(["Copy ID", "Copy link"].map(name => page.getByRole("button", { name, exact: true }).boundingBox())); expect(actionBoxes.every(box => box !== null)).toBe(true); - expect(actionBoxes.map(box => box!.y)).toEqual([actionBoxes[0]!.y, actionBoxes[0]!.y, actionBoxes[0]!.y]); - expect(actionBoxes.map(box => box!.height)).toEqual([actionBoxes[0]!.height, actionBoxes[0]!.height, actionBoxes[0]!.height]); + expect(actionBoxes.map(box => box!.y)).toEqual([actionBoxes[0]!.y, actionBoxes[0]!.y]); + expect(actionBoxes.map(box => box!.height)).toEqual([actionBoxes[0]!.height, actionBoxes[0]!.height]); const facts = page.locator(".sq-request-detail dl").first().locator(":scope > div"); await expect(facts).toHaveCount(5); const factLayout = await facts.evaluateAll(rows => rows.map(row => { @@ -219,7 +219,7 @@ test("shows newest requests and an accessible readable detail history", async ({ expect(new URL(page.url()).search).toBe(""); const gitUri = "git://git.example.com/demo/refs%2Fheads%2Fweb-demo/3333333333333333333333333333333333333333"; const gitRequest = await submitRequest(`${gitUri}?sq-files=demo%2F00%2Ffile.txt`); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.reload(); await expect(page.getByRole("link", { name: gitRequest, exact: true })).toBeVisible(); const changeLink = page.getByRole("link", { name: gitUri, exact: true }); await expect(changeLink).toBeVisible(); @@ -301,7 +301,72 @@ test("paginates change history through more than 500 queue requests", async ({ p await page.reload(); await expect(page.getByRole("link", { name: oldest, exact: true })).toBeVisible(); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("link", { name: "Latest history", exact: true }).click(); await expect(page.getByRole("link", { name: newest, exact: true })).toBeVisible(); expect(new URL(page.url()).search).toBe(""); }); + +test("refreshes from the header only while auto-refresh is enabled", async ({ page }) => { + await page.goto(`/${queue}`); + const automatic = page.getByRole("banner").getByRole("checkbox", { name: "Auto refresh every 10s" }); + await expect(automatic).not.toBeChecked(); + const now = new Date(); + await page.clock.install({ time: now }); + await page.clock.pauseAt(now); + let refreshRequests = 0; + const isRefresh = (request: import("@playwright/test").Request) => + request.headers()["rsc"] === "1" && new URL(request.url()).pathname === new URL(page.url()).pathname; + 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 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 page.getByRole("banner").getByText("Auto refresh every 10s", { exact: true }).click(); + await expect(automatic).not.toBeChecked(); + await page.clock.fastForward(30_000); + expect(refreshRequests).toBe(2); +}); + +test.describe("time zone selection", () => { + test.use({ timezoneId: "America/New_York" }); + test("switches queue, request history and change timestamps from the shared header", async ({ page }) => { + const expectTimeZone = async (zone: string) => { + await expect.poll(() => page.locator("time[datetime]").evaluateAll((times, timeZone) => { + const formatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone }); + return times.length > 0 && times.every(time => time.textContent === formatter.format(new Date(time.getAttribute("datetime")!))); + }, zone)).toBe(true); + }; + const timeZones = page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true }); + await page.goto(`/${queue}`); + await expect(timeZones.getByRole("option", { name: "Browser", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("America/New_York"); + await timeZones.getByRole("option", { name: "UTC", exact: true }).click(); + await expectTimeZone("UTC"); + await page.locator(".sq-request-list__item td:first-child a").first().click(); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + await expect(page.getByText("Received", { exact: true })).toBeVisible(); + await page.getByRole("link", { name: /^History/ }).click(); + await expect(page.getByRole("list", { name: "Request history", exact: true })).toBeVisible(); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + const changePath = `/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fpagination`; + for (const path of [changePath, `${changePath}/7777777777777777777777777777777777777777`]) { + await page.goto(path); + await expect(page.getByRole("table", { name: "Change submissions" })).toBeVisible(); + await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expectTimeZone("UTC"); + } + await timeZones.getByRole("option", { name: "Browser", exact: true }).click(); + await expectTimeZone("America/New_York"); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + }); +}); diff --git a/web/test/package/consumer-runtime.test.mjs b/web/test/package/consumer-runtime.test.mjs index 13879cc70..21c46ecbb 100644 --- a/web/test/package/consumer-runtime.test.mjs +++ b/web/test/package/consumer-runtime.test.mjs @@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { BaseProvider, LightTheme } from "baseui"; import { Server } from "styletron-engine-atomic"; import { Provider } from "styletron-react"; -import { SubmitQueueApp, SubmitQueueShell } from "@submitqueue/web-submitqueue"; +import { SubmitQueueApp } from "@submitqueue/web-submitqueue"; import { createSubmitQueueWeb, WebPaths } from "@submitqueue/web-submitqueue/server"; import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; @@ -22,8 +22,7 @@ test("renders a packaged application with its host providers", async () => { const engine = new Server(); const html = renderToStaticMarkup(createElement(Provider, { value: engine }, createElement(BaseProvider, { theme: LightTheme }, - createElement(SubmitQueueShell, { homeHref: "/queues" }, - createElement(SubmitQueueApp, { model: result.model }))))); + createElement(SubmitQueueApp, { model: result.model })))); assert.ok(engine.getCss().length > 0); assert.ok(html.includes('href="/queues/demo-queue/request/demo-queue/1"')); assert.ok(html.includes('aria-label="Queue requests"')); diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs index 677170500..17c0bb3cd 100644 --- a/web/test/package/react18/react18-runtime.test.mjs +++ b/web/test/package/react18/react18-runtime.test.mjs @@ -6,7 +6,7 @@ import { renderToString } from "react-dom/server"; import { BaseProvider, createTheme, DarkTheme, LightTheme } from "baseui"; import { Client, Server } from "styletron-engine-atomic"; import { Provider } from "styletron-react"; -import { SubmitQueueApp, SubmitQueueShell } from "@submitqueue/web-submitqueue"; +import { SubmitQueueApp } from "@submitqueue/web-submitqueue"; import { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; @@ -44,7 +44,7 @@ test("renders and hydrates packaged pages with React 18 and host themes", async for (const route of routes) { const result = await web.handle(route); assert.equal(result.kind, "render", `${JSON.stringify(route)} did not render`); - const page = createElement(SubmitQueueShell, null, createElement(SubmitQueueApp, { model: result.model })); + const page = createElement(SubmitQueueApp, { model: result.model }); addPage(route, page); } @@ -106,9 +106,11 @@ test("renders and hydrates packaged pages with React 18 and host themes", async document.body.append(expected); assert.equal(dom.window.getComputedStyle(container.querySelector(".sq-app")).backgroundColor, dom.window.getComputedStyle(expected).backgroundColor, "the page must inherit its host's background"); - expected.style.backgroundColor = theme.colors.buttonPrimaryFill; - assert.equal(dom.window.getComputedStyle(container.querySelector("button")).backgroundColor, - dom.window.getComputedStyle(expected).backgroundColor, "refresh must inherit its host's button theme"); + expected.style.color = theme.colors.contentPrimary; + const selectedTimeZone = container.querySelector('[role="option"][aria-selected="true"]'); + assert.ok(selectedTimeZone); + assert.equal(dom.window.getComputedStyle(selectedTimeZone).color, + dom.window.getComputedStyle(expected).color, "the selected time zone must inherit its host's text color"); expected.remove(); if (route === "/repo%2Fmain/request/1") { assert.match(container.textContent, /example-project/u);