diff --git a/web/BUILD.bazel b/web/BUILD.bazel index a2514d47..2717a9c6 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -11,6 +11,10 @@ test_suite( tests = [ "//web/api:generate_tests", "//web/api:test", + "//web/package_test:consumer_typecheck_typecheck_test", + "//web/package_test:pack_contents_test", + "//web/submitqueue:test", + "//web/submitqueue:typecheck_typecheck_test", ], ) diff --git a/web/package_test/BUILD.bazel b/web/package_test/BUILD.bazel new file mode 100644 index 00000000..577ba3a2 --- /dev/null +++ b/web/package_test/BUILD.bazel @@ -0,0 +1,52 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@rules_python//python:defs.bzl", "py_test") + +package(default_visibility = ["//visibility:public"]) + +npm_link_package( + name = "node_modules/@submitqueue/api", + src = "//web/api:pkg", +) + +npm_link_package( + name = "node_modules/@submitqueue/web-submitqueue", + src = "//web/submitqueue:pkg", +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_project( + name = "consumer_typecheck", + srcs = ["consumer.ts"], + declaration = False, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@submitqueue/api", + ":node_modules/@submitqueue/web-submitqueue", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@types/node", + "//web:node_modules/react", + "//web:node_modules/react-dom", + ], +) + +py_test( + name = "pack_contents_test", + srcs = ["pack_contents_test.py"], + main = "pack_contents_test.py", + legacy_create_init = 0, + args = [ + "$(rootpath //web/api:pkg.pack)", + "$(rootpath //web/submitqueue:pkg.pack)", + ], + data = [ + "//web/api:pkg.pack", + "//web/submitqueue:pkg.pack", + ], +) diff --git a/web/package_test/README.md b/web/package_test/README.md new file mode 100644 index 00000000..85893357 --- /dev/null +++ b/web/package_test/README.md @@ -0,0 +1,7 @@ +# Package consumer checks + +This package validates the artifacts that downstream consumers receive rather than importing workspace source directly. + +`//web/package_test:pack_contents_test` checks the Bazel-created npm tarballs for the generated gateway API and the library's root, `./server`, and `./testing` declaration files. `//web/package_test:consumer_typecheck_typecheck_test` links the packaged outputs into an isolated TypeScript consumer and verifies all public entry points. + +These checks complement the Next reference-host build: the package tests enforce publication shape, while the host proves framework integration. diff --git a/web/package_test/consumer.ts b/web/package_test/consumer.ts new file mode 100644 index 00000000..5e3a6491 --- /dev/null +++ b/web/package_test/consumer.ts @@ -0,0 +1,8 @@ +import { AutoRefresh, RequestStatus } from "@submitqueue/web-submitqueue"; +import { loadRequestList } from "@submitqueue/web-submitqueue/server"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/testing"; + +void AutoRefresh; +void RequestStatus; +void loadRequestList; +void createFakeGatewayReader; diff --git a/web/package_test/pack_contents_test.py b/web/package_test/pack_contents_test.py new file mode 100644 index 00000000..1f0ed3df --- /dev/null +++ b/web/package_test/pack_contents_test.py @@ -0,0 +1,59 @@ +import sys +import json +import tarfile +from pathlib import Path + + +_EXPECTED_API_FILES = { + "package/dist/api/submitqueue/gateway/proto/gateway_pb.d.ts", + "package/dist/api/submitqueue/gateway/proto/gateway_pb.js", + "package/README.md", +} + +_EXPECTED_WEB_FILES = { + "package/dist/index.d.ts", + "package/dist/server.d.ts", + "package/dist/testing.d.ts", + "package/README.md", +} + + +def _missing_files(archive_path: Path, expected: set[str]) -> list[str]: + with tarfile.open(archive_path) as archive: + files = {member.name for member in archive.getmembers()} + return sorted(expected - files) + + +def main() -> int: + if len(sys.argv) != 3: + raise RuntimeError("expected API and web package archives") + + missing = { + str(archive): absent + for archive, expected in ( + (Path(sys.argv[1]), _EXPECTED_API_FILES), + (Path(sys.argv[2]), _EXPECTED_WEB_FILES), + ) + if (absent := _missing_files(archive, expected)) + } + if missing: + for archive, files in missing.items(): + print(f"{archive} is missing: {', '.join(files)}", file=sys.stderr) + return 1 + with tarfile.open(Path(sys.argv[2])) as archive: + package_file = archive.extractfile("package/package.json") + package = json.load(package_file) + if "next" in package.get("peerDependencies", {}) or "next" in package.get("dependencies", {}): + print("The presentation library must not require Next.js.", file=sys.stderr) + return 1 + for member in archive.getmembers(): + if member.name.endswith((".js", ".d.ts")): + source = archive.extractfile(member).read().decode("utf-8") + if '"next/' in source or "'next/" in source: + print(f"{member.name} imports host-owned Next.js.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/package_test/tsconfig.json b/web/package_test/tsconfig.json new file mode 100644 index 00000000..781744e9 --- /dev/null +++ b/web/package_test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023" + }, + "include": ["consumer.ts"] +} diff --git a/web/submitqueue/BUILD.bazel b/web/submitqueue/BUILD.bazel index 47874234..923568e8 100644 --- a/web/submitqueue/BUILD.bazel +++ b/web/submitqueue/BUILD.bazel @@ -1,3 +1,102 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/submitqueue:vitest/package_json.bzl", vitest = "bin") + package(default_visibility = ["//visibility:public"]) -exports_files(["package.json"]) +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_config( + name = "tsconfig_build", + src = "tsconfig.build.json", + deps = [":tsconfig"], +) + +ts_project( + name = "lib", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ], exclude = [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/test-setup.ts", + ]), + declaration = True, + declaration_map = True, + no_emit = False, + out_dir = "dist", + root_dir = "src", + transpiler = "tsc", + tsconfig = ":tsconfig_build", + deps = [ + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@submitqueue/api", + ":node_modules/@types/react", + ":node_modules/react", + ":node_modules/server-only", + ], +) + +ts_project( + name = "typecheck", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]) + ["vitest.config.ts"], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@submitqueue/api", + ":node_modules/@testing-library/jest-dom", + ":node_modules/@testing-library/react", + ":node_modules/@types/react", + ":node_modules/@types/react-dom", + ":node_modules/jsdom", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + ":node_modules/vitest", + ], +) + +npm_package( + name = "pkg", + package = "@submitqueue/web-submitqueue", + packable = True, + srcs = [ + ":lib", + ":lib_types", + "README.md", + "package.json", + ], +) + +vitest.vitest_test( + name = "test", + args = [ + "run", + "--config", + "vitest.config.ts", + ], + chdir = package_name(), + data = [ + ":node_modules", + ":node_modules/vitest", + "//web:node_modules/vitest", + "vitest.config.ts", + ] + glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]) + ["test/server-only.ts"], +) diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md new file mode 100644 index 00000000..84dafae6 --- /dev/null +++ b/web/submitqueue/README.md @@ -0,0 +1,9 @@ +# SubmitQueue web library + +`@submitqueue/web-submitqueue` owns serializable models, gateway loaders, safe timestamps, status/error presentation, route helpers, refresh controls, and deterministic gateway fakes. + +The root entry point is client-safe; `./server` contains server-only gateway mapping, and `./testing` contains test fixtures. React, Connect, and Protobuf-ES are peer contracts. The package has no Next.js dependency. + +Hosts own routes, credentials, queue selection, transport creation, deadlines, dynamic rendering, and deployment. `AutoRefresh` takes a host callback whose promise settles after refreshing finishes. + +Bazel owns compilation, unit/type tests, package tarballs, and isolated package-consumer checks. Page components are added in the following stack changes. diff --git a/web/submitqueue/package.json b/web/submitqueue/package.json index 9d5b67ba..98eb9c52 100644 --- a/web/submitqueue/package.json +++ b/web/submitqueue/package.json @@ -21,7 +21,12 @@ "import": "./dist/testing.js" } }, - "scripts": {}, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json", + "lint": "tsc --noEmit", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, "dependencies": { "@submitqueue/api": "0.1.0", "server-only": "^0.0.1" diff --git a/web/submitqueue/src/auto-refresh.test.tsx b/web/submitqueue/src/auto-refresh.test.tsx new file mode 100644 index 00000000..44516dec --- /dev/null +++ b/web/submitqueue/src/auto-refresh.test.tsx @@ -0,0 +1,136 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AutoRefresh } from "./auto-refresh"; + +describe("AutoRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + }); + + afterEach(() => vi.useRealTimers()); + + it("uses the interval plus jitter and continues polling", async () => { + const refresh = vi.fn(async () => undefined); + render( 0.5} refresh={refresh} />); + + await act(async () => vi.advanceTimersByTime(2_249)); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(2_250)); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("keeps manual and timer refreshes single-flight beyond multiple intervals", async () => { + let finish: (() => void) | undefined; + const refresh = vi.fn(() => new Promise((resolve) => (finish = resolve))); + render(); + + 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?.()); + }); + + it("backs off transient failures up to the configured cap", async () => { + const refresh = vi.fn(async () => undefined); + render( + 0} + refresh={refresh} + transientFailureCount={4} + />, + ); + + await act(async () => vi.advanceTimersByTime(29_999)); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("progressively backs off consecutive server failures", async () => { + const refresh = vi.fn(async () => undefined); + render( + 0} + refresh={refresh} + transientFailureCount={1} + />, + ); + + await act(async () => vi.advanceTimersByTime(200)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(399)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTime(799)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it("pauses while offline and resumes when online", async () => { + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + const refresh = vi.fn(async () => undefined); + render(); + + expect(screen.getByText("Automatic refresh paused")).toBeTruthy(); + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + fireEvent(window, new Event("online")); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("pauses while hidden and resumes when visible", async () => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + const refresh = vi.fn(async () => undefined); + render(); + + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + fireEvent(document, new Event("visibilitychange")); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("stops automatic polling for terminal detail views", async () => { + const refresh = vi.fn(async () => undefined); + render(); + + 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); + }); + + it("does not poll a terminal list failure", async () => { + const refresh = vi.fn(async () => undefined); + render( + , + ); + + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/web/submitqueue/src/auto-refresh.tsx b/web/submitqueue/src/auto-refresh.tsx new file mode 100644 index 00000000..4f573e62 --- /dev/null +++ b/web/submitqueue/src/auto-refresh.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface AutoRefreshProps { + terminal?: boolean; + intervalMs?: number; + jitterMs?: number; + maxBackoffMs?: number; + transientFailureCount?: number; + refresh: () => void | Promise; + random?: () => number; +} + +function pageCanRefresh(): boolean { + return document.visibilityState !== "hidden" && navigator.onLine; +} + +export function AutoRefresh({ + terminal = false, + intervalMs = 2_000, + jitterMs = 500, + maxBackoffMs = 30_000, + transientFailureCount = 0, + refresh, + random = Math.random, +}: AutoRefreshProps) { + const [refreshing, setRefreshing] = useState(false); + const [paused, setPaused] = useState(false); + const [cycle, setCycle] = useState(0); + const inFlight = useRef(false); + const localFailures = useRef(transientFailureCount); + const latestTransientFailureCount = useRef(transientFailureCount); + const timer = useRef | null>(null); + latestTransientFailureCount.current = transientFailureCount; + + const runRefresh = useCallback(async () => { + if (inFlight.current || !pageCanRefresh()) { + setPaused(!pageCanRefresh()); + return; + } + inFlight.current = true; + setRefreshing(true); + try { + await refresh(); + const serverFailures = latestTransientFailureCount.current; + localFailures.current = + serverFailures === 0 + ? 0 + : Math.max(localFailures.current + 1, serverFailures); + } catch { + localFailures.current = Math.max( + localFailures.current + 1, + latestTransientFailureCount.current, + ); + } finally { + inFlight.current = false; + setRefreshing(false); + setCycle((value) => value + 1); + } + }, [refresh]); + + useEffect(() => { + const updateAvailability = () => setPaused(!pageCanRefresh()); + document.addEventListener("visibilitychange", updateAvailability); + window.addEventListener("online", updateAvailability); + window.addEventListener("offline", updateAvailability); + updateAvailability(); + return () => { + document.removeEventListener("visibilitychange", updateAvailability); + window.removeEventListener("online", updateAvailability); + window.removeEventListener("offline", updateAvailability); + }; + }, []); + + useEffect(() => { + if (terminal || paused) { + return; + } + const failures = Math.max(transientFailureCount, localFailures.current); + const multiplier = 2 ** Math.min(failures, 20); + const backoff = Math.min(intervalMs * multiplier, maxBackoffMs); + const delay = backoff + Math.floor(random() * (jitterMs + 1)); + timer.current = setTimeout(() => { + void runRefresh(); + }, delay); + return () => { + if (timer.current !== null) { + clearTimeout(timer.current); + timer.current = null; + } + }; + }, [cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]); + + return ( +
+ + + {terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : + transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"} + +
+ ); +} diff --git a/web/submitqueue/src/components.test.tsx b/web/submitqueue/src/components.test.tsx new file mode 100644 index 00000000..95c95192 --- /dev/null +++ b/web/submitqueue/src/components.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ErrorState, RequestStatus } from "./components"; +import type { RequestDetailModel, RequestListModel } from "./models"; + +const request = { + sqid: "demo-queue/1", + queue: "demo-queue", + changeUris: ["github://github.com/uber/submitqueue/pull/1/abc"], + receivedAtMs: 1_700_000_000_000, + status: "speculating", + lastError: null, + metadata: { batch: "batch-1" }, +}; + +describe("request components", () => { + + + it("shows unknown statuses safely", () => { + render(); + expect(screen.getByText("New Pipeline Step").getAttribute("data-tone")).toBe("neutral"); + }); + + it("uses an alert for a full-page safe error", () => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toContain("Something went wrong"); + }); +}); diff --git a/web/submitqueue/src/components.tsx b/web/submitqueue/src/components.tsx new file mode 100644 index 00000000..c94faa8a --- /dev/null +++ b/web/submitqueue/src/components.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import type { + ChangeDetailModel, HistoryEventModel, QueueModel, RequestDetailModel, + RequestListModel, RequestSummaryModel, WebError, LoadResult, +} from "./models.js"; +import { WebPaths } from "./paths.js"; +import { statusDisplay } from "./status.js"; + +const timestampFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", timeStyle: "long", timeZone: "UTC", +}); + +export function Timestamp({ value }: { value: number }) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? : + ; +} + +function Metadata({ values }: { values: Record }) { + return
{Object.entries(values).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => +
{key}
{value}
+ )}
; +} + +export function RequestStatus({ status }: { status: string }) { + const display = statusDisplay(status); + return {display.label}; +} + +export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) { + return
+

{error.title}

{error.message}

+
; +} diff --git a/web/submitqueue/src/index.ts b/web/submitqueue/src/index.ts new file mode 100644 index 00000000..7791df53 --- /dev/null +++ b/web/submitqueue/src/index.ts @@ -0,0 +1,31 @@ +"use client"; + +export { AutoRefresh, type AutoRefreshProps } from "./auto-refresh.js"; +export { + ErrorState, + Timestamp, + RequestStatus, +} from "./components.js"; +export type { + QueueModel, + ChangeDetailModel, + ChangeSubmissionModel, + HistoryEventModel, + HistoryEventType, + LoadResult, + RequestDetailModel, + RequestListModel, + RequestSummaryModel, + StatusDisplay, + StatusTone, + WebError, + WebErrorKind, +} from "./models.js"; +export { + decodePathSegment, + type RequestPathOptions, + type RequestsPathOptions, + WebPaths, + type WebPathsOptions, +} from "./paths.js"; +export { isTerminalStatus, knownRequestStatuses, statusDisplay } from "./status.js"; diff --git a/web/submitqueue/src/models.ts b/web/submitqueue/src/models.ts new file mode 100644 index 00000000..b4331d33 --- /dev/null +++ b/web/submitqueue/src/models.ts @@ -0,0 +1,78 @@ +export type StatusTone = "neutral" | "progress" | "success" | "danger" | "warning"; + +export interface StatusDisplay { + label: string; + tone: StatusTone; + terminal: boolean; +} + +export interface RequestSummaryModel { + sqid: string; + queue: string; + changeUris: string[]; + receivedAtMs: number; + status: string; + lastError: string | null; + metadata: Record; +} + +export type HistoryEventType = "status" | "event" | "unknown"; + +export interface HistoryEventModel { + timestampMs: number; + type: HistoryEventType; + status: string | null; + event: string | null; + lastError: string | null; + metadata: Record; +} + +export interface RequestListModel { + queue: string; + receivedAtOrAfterMs: number; + receivedBeforeMs: number; + requests: RequestSummaryModel[]; + nextPageToken: string | null; +} + +export interface RequestDetailModel { + request: RequestSummaryModel; + history: HistoryEventModel[]; + historyError: WebError | null; +} + +export type WebErrorKind = "user" | "not-found" | "transient" | "internal"; + +export interface QueueModel { + name: string; + description: string; +} + +export interface ChangeSubmissionModel { + request: RequestSummaryModel; + version: string; + versionHref: string; +} + +export interface ChangeDetailModel { + queue: string; + provider: string; + host: string; + review: string; + repository: string; + pinnedVersion: string | null; + logicalHref?: string; + submissions: ChangeSubmissionModel[]; + window: { fromMs: number; toMs: number } | null; +} + +export interface WebError { + kind: WebErrorKind; + title: string; + message: string; + retryable: boolean; +} + +export type LoadResult = + | { ok: true; data: T } + | { ok: false; error: WebError }; diff --git a/web/submitqueue/src/paths.test.ts b/web/submitqueue/src/paths.test.ts new file mode 100644 index 00000000..b651a670 --- /dev/null +++ b/web/submitqueue/src/paths.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decodePathSegment, WebPaths } from "./paths"; + +describe("WebPaths", () => { + it("keeps the queue and complete sqid readable in the request path", () => { + const paths = new WebPaths(); + + expect(paths.request("demo-queue", "demo-queue/42")).toBe( + "/demo-queue/request/demo-queue/42", + ); + }); + + it("uses only the host's opaque cursor while paging", () => { + const paths = new WebPaths({ basePath: "/submitqueue/" }); + const href = paths.requests("demo queue", { + pageToken: "opaque+/=", + }); + + expect(href).toBe( + "/submitqueue/demo%20queue?page=opaque%2B%2F%3D", + ); + }); + + it("URL-escapes reserved characters without interpreting the sqid", () => { + const paths = new WebPaths(); + + expect(paths.request("demo-queue", "opaque/id?query#fragment")).toBe( + "/demo-queue/request/opaque/id%3Fquery%23fragment", + ); + }); + + it.each([".", "..", "~.", "~value"])("round-trips %s without URL dot normalization", (id) => { + const paths = new WebPaths(); + const url = new URL(paths.request(id, `${id}/42`), "https://example.test"); + const [queue, resource, ...sqid] = url.pathname.slice(1).split("/").map(decodeURIComponent); + expect(resource).toBe("request"); + expect(decodePathSegment(queue!)).toBe(id); + expect(sqid.map(decodePathSegment).join("/")).toBe(`${id}/42`); + }); + + it("makes the history view shareable without consuming part of an opaque ID", () => { + expect(new WebPaths().request("demo-queue", "42", { view: "history" })).toBe("/demo-queue/request/42?view=history"); + }); +}); diff --git a/web/submitqueue/src/paths.ts b/web/submitqueue/src/paths.ts new file mode 100644 index 00000000..4e1026b9 --- /dev/null +++ b/web/submitqueue/src/paths.ts @@ -0,0 +1,60 @@ +export interface RequestsPathOptions { + pageToken?: string | null; +} + +export interface WebPathsOptions { + basePath?: string; +} + +export interface RequestPathOptions extends RequestsPathOptions { + view?: "summary" | "history"; +} + +function encodePathSegment(value: string): string { + // A visible prefix prevents browser normalization of dot-only opaque values. + return value === "." || value === ".." || value.startsWith("~") + ? `~${encodeURIComponent(value)}` + : encodeURIComponent(value); +} + +export function decodePathSegment(value: string): string { + return value.startsWith("~") ? value.slice(1) : value; +} + +function encodePath(value: string): string { + return value.split("/").map(encodePathSegment).join("/"); +} + +export class WebPaths { + readonly basePath: string; + + constructor(options: WebPathsOptions = {}) { + const requestedPath = options.basePath ?? ""; + this.basePath = requestedPath.endsWith("/") + ? requestedPath.slice(0, -1) + : requestedPath; + } + + requests(queue: string, options: RequestsPathOptions = {}): string { + const parameters = new URLSearchParams(); + if (options.pageToken) { + parameters.set("page", options.pageToken); + } + const query = parameters.toString(); + const queuePath = `${this.basePath}/${encodePathSegment(queue)}`; + return query === "" ? queuePath : `${queuePath}?${query}`; + } + + directory(): string { + return this.basePath || "/"; + } + + request(queue: string, sqid: string, options: RequestPathOptions = {}): string { + const query = new URL(this.requests(queue, options), "http://paths.invalid").searchParams; + if (options.view === "history") { + query.set("view", "history"); + } + const suffix = query.toString(); + return `${this.requests(queue)}/request/${encodePath(sqid)}${suffix ? `?${suffix}` : ""}`; + } +} diff --git a/web/submitqueue/src/server.test.ts b/web/submitqueue/src/server.test.ts new file mode 100644 index 00000000..bd9e9fb1 --- /dev/null +++ b/web/submitqueue/src/server.test.ts @@ -0,0 +1,234 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeGatewayReader, gatewayHistoryFixture, gatewayRequestFixture } from "./testing"; + +import { + classifyGatewayError, + loadRequestDetail, + loadRequestList, + requestDetailIsComplete, + requestDetailRefreshState, + safeInt64ToNumber, +} from "./server"; + +describe("server presentation mapping", () => { + beforeEach(() => vi.clearAllMocks()); + + it("converts int64 only within JavaScript's safe range", () => { + expect(safeInt64ToNumber(1_700_000_000_000n)).toBe(1_700_000_000_000); + expect(safeInt64ToNumber(BigInt(Number.MAX_SAFE_INTEGER) + 1n)).toBeNull(); + expect(safeInt64ToNumber("not-an-integer")).toBeNull(); + }); + + it("maps a request page while preserving its bounds", async () => { + const reader = createFakeGatewayReader({ nextPageToken: "next" }); + const result = await loadRequestList(() => reader, { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + pageToken: "current", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + nextPageToken: "next", + requests: [{ sqid: "demo-queue/1", receivedAtMs: 1_700_000_000_000 }], + }, + }); + }); + + it("leaves list, summary, and history deadlines to the host transport", async () => { + const fake = createFakeGatewayReader(); + const list = vi.fn(fake.list); + const getRequestSummaryByID = vi.fn(fake.getRequestSummaryByID); + const getRequestHistoryByID = vi.fn(fake.getRequestHistoryByID); + const reader = { list, getRequestSummaryByID, getRequestHistoryByID }; + + await loadRequestList(() => reader, { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + }); + await loadRequestDetail(() => reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(list.mock.calls[0]).toHaveLength(1); + expect(getRequestSummaryByID.mock.calls[0]).toHaveLength(1); + expect(getRequestHistoryByID.mock.calls[0]).toHaveLength(1); + }); + + it("returns an explicit invalid-input result before resolving a client", async () => { + const resolver = vi.fn(() => createFakeGatewayReader()); + const result = await loadRequestList(resolver, { + queue: "demo-queue", + receivedAtOrAfterMs: 200, + receivedBeforeMs: 100, + }); + + expect(result).toMatchObject({ ok: false, error: { kind: "user" } }); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("keeps the summary available when history has a transient failure", async () => { + const onGatewayError = vi.fn(); + const historyCause = new ConnectError("private upstream text", Code.Unavailable); + const reader = createFakeGatewayReader({ + summary: gatewayRequestFixture({ status: "landed" }), + historyError: historyCause, + }); + const result = await loadRequestDetail( + () => reader, + { + queue: "demo-queue", + sqid: "demo-queue/1", + }, + { diagnostics: { onGatewayError } }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { + request: { status: "landed" }, + history: [], + historyError: { kind: "transient", retryable: true }, + }, + }); + expect(onGatewayError).toHaveBeenCalledWith({ + operation: "history", + queue: "demo-queue", + sqid: "demo-queue/1", + connectCode: Code.Unavailable, + cause: historyCause, + }); + expect(JSON.stringify(result)).not.toContain("private upstream text"); + if (result.ok) { + expect(requestDetailIsComplete(result.data)).toBe(false); + expect(requestDetailRefreshState(result.data)).toEqual({ + terminal: false, + transientFailureCount: 1, + }); + } + }); + + it("stops a terminal summary when history cannot converge", async () => { + const reader = createFakeGatewayReader({ + summary: gatewayRequestFixture({ status: "landed" }), + historyError: new ConnectError("invalid history", Code.PermissionDenied), + }); + const result = await loadRequestDetail(() => reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(result.ok && requestDetailRefreshState(result.data)).toEqual({ + terminal: true, + transientFailureCount: 0, + }); + }); + + it("waits for history to contain the terminal summary status", async () => { + const summary = gatewayRequestFixture({ status: "landed" }); + const incomplete = await loadRequestDetail( + () => + createFakeGatewayReader({ + summary, + history: [gatewayHistoryFixture({ status: "landing" })], + }), + { queue: "demo-queue", sqid: "demo-queue/1" }, + ); + const complete = await loadRequestDetail( + () => + createFakeGatewayReader({ + summary, + history: [gatewayHistoryFixture({ status: "landed" })], + }), + { queue: "demo-queue", sqid: "demo-queue/1" }, + ); + + expect(incomplete.ok && requestDetailIsComplete(incomplete.data)).toBe(false); + expect(complete.ok && requestDetailIsComplete(complete.data)).toBe(true); + }); + + it("maps chronological status and occurrence history", async () => { + const reader = createFakeGatewayReader({ + history: [ + gatewayHistoryFixture({ timestampMs: 1n, status: "started" }), + gatewayHistoryFixture({ + timestampMs: 2n, + type: "event", + status: "", + event: "building", + metadata: { build_url: "https://build.example/1" }, + }), + ], + }); + const result = await loadRequestDetail(() => reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + history: [ + { timestampMs: 1, type: "status", status: "started" }, + { timestampMs: 2, type: "event", event: "building" }, + ], + historyError: null, + }, + }); + }); +}); + +describe("gateway error classification", () => { + it.each([ + [Code.InvalidArgument, "user", false], + [Code.NotFound, "not-found", false], + [Code.ResourceExhausted, "user", false], + [Code.Unavailable, "transient", true], + [Code.DeadlineExceeded, "transient", true], + [Code.Internal, "internal", false], + ] as const)("classifies code %s", (code, kind, retryable) => { + const classified = classifyGatewayError(new ConnectError("raw gateway detail", code)); + + expect(classified).toMatchObject({ kind, retryable }); + expect(classified.message).not.toContain("raw gateway detail"); + }); + + it("does not expose arbitrary errors", () => { + expect(classifyGatewayError(new Error("database password"))).toMatchObject({ + kind: "internal", + retryable: false, + }); + }); + + it("emits one structured diagnostic without exposing the cause", async () => { + const cause = new ConnectError("private upstream detail", Code.Unavailable); + const onGatewayError = vi.fn(); + const result = await loadRequestList( + () => createFakeGatewayReader({ listError: cause }), + { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + }, + { diagnostics: { onGatewayError } }, + ); + + expect(onGatewayError).toHaveBeenCalledOnce(); + expect(onGatewayError).toHaveBeenCalledWith({ + operation: "list", + queue: "demo-queue", + sqid: null, + connectCode: Code.Unavailable, + cause, + }); + expect(JSON.stringify(result)).not.toContain("private upstream detail"); + }); +}); diff --git a/web/submitqueue/src/server.ts b/web/submitqueue/src/server.ts new file mode 100644 index 00000000..f638107a --- /dev/null +++ b/web/submitqueue/src/server.ts @@ -0,0 +1,395 @@ +import "server-only"; + +import { Code, ConnectError, type Client } from "@connectrpc/connect"; +import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway"; +import type { + HistoryEventModel, + ChangeDetailModel, + LoadResult, + RequestDetailModel, + RequestListModel, + RequestSummaryModel, + WebError, +} from "./models.js"; +import { isTerminalStatus } from "./status.js"; + +export { decodePathSegment, WebPaths } from "./paths.js"; +export { isTerminalStatus, statusDisplay } from "./status.js"; + +type Int64Value = bigint | number | string; + +export interface GatewayCallOptions { + timeoutMs?: number; +} + +export interface GatewayRequestSummary { + sqid: string; + queue: string; + changeUris: readonly string[]; + receivedAtMs: Int64Value; + status: string; + lastError: string; + metadata: Readonly>; +} + +export interface GatewayHistoryEvent { + timestampMs: Int64Value; + status: string; + lastError: string; + metadata: Readonly>; + type: string; + event: string; +} + +export interface GatewayReader { + list( + input: { + queue: string; + receivedAtOrAfterMs: bigint; + receivedBeforeMs: bigint; + pageSize: number; + pageToken: string; + }, + options?: GatewayCallOptions, + ): Promise<{ + requests: readonly GatewayRequestSummary[]; + nextPageToken: string; + }>; + getRequestSummaryByID( + input: { sqid: string; queue: string }, + options?: GatewayCallOptions, + ): Promise<{ + request?: GatewayRequestSummary | undefined; + }>; + getRequestHistoryByID( + input: { sqid: string; queue: string }, + options?: GatewayCallOptions, + ): Promise<{ + events: readonly GatewayHistoryEvent[]; + }>; +} + +export type GatewayResolver = (queue: string) => GatewayReader; + +export type GatewayOperation = "list" | "summary" | "history" | "change"; + +export interface GatewayDiagnostic { + operation: GatewayOperation; + queue: string; + sqid: string | null; + connectCode: Code | null; + cause: unknown; +} + +export interface GatewayDiagnostics { + onGatewayError(diagnostic: GatewayDiagnostic): void; +} + +export interface GatewayLoadOptions { + diagnostics?: GatewayDiagnostics; +} + +export type SubmitQueueGatewayClient = Client; + +export function gatewayReader(client: SubmitQueueGatewayClient): GatewayReader { + return client; +} + +export interface LoadRequestListInput { + queue: string; + receivedAtOrAfterMs: number; + receivedBeforeMs: number; + pageSize?: number; + pageToken?: string; +} + +export interface LoadRequestDetailInput { + queue: string; + sqid: string; +} + +const INVALID_INPUT: WebError = { + kind: "user", + title: "Invalid request", + message: "The request parameters are invalid.", + retryable: false, +}; + +const INTERNAL_ERROR: WebError = { + kind: "internal", + title: "Something went wrong", + message: "SubmitQueue could not load this information.", + retryable: false, +}; + +export function safeInt64ToNumber(value: Int64Value): number | null { + let converted: bigint; + if (typeof value === "bigint") { + converted = value; + } else if (typeof value === "number") { + return Number.isSafeInteger(value) ? value : null; + } else { + try { + converted = BigInt(value); + } catch { + return null; + } + } + if (converted > BigInt(Number.MAX_SAFE_INTEGER) || converted < BigInt(Number.MIN_SAFE_INTEGER)) { + return null; + } + return Number(converted); +} + +export function classifyGatewayError(error: unknown): WebError { + if (!(error instanceof ConnectError)) { + return INTERNAL_ERROR; + } + switch (error.code) { + case Code.InvalidArgument: + return INVALID_INPUT; + case Code.NotFound: + return { + kind: "not-found", + title: "Request not found", + message: "The requested SubmitQueue request was not found.", + retryable: false, + }; + case Code.ResourceExhausted: + return { + kind: "user", + title: "Request limit reached", + message: "The requested result set is too large. Narrow the time window and try again.", + retryable: false, + }; + case Code.Unavailable: + case Code.DeadlineExceeded: + return { + kind: "transient", + title: "SubmitQueue is unavailable", + message: "SubmitQueue could not be reached. Try again shortly.", + retryable: true, + }; + default: + return INTERNAL_ERROR; + } +} + +function reportGatewayError( + options: GatewayLoadOptions, + operation: GatewayOperation, + queue: string, + sqid: string | null, + cause: unknown, +): void { + try { + options.diagnostics?.onGatewayError({ + operation, + queue, + sqid, + connectCode: cause instanceof ConnectError ? cause.code : null, + cause, + }); + } catch { + // Diagnostics must never replace the sanitized loader result. + } +} + +export function requestDetailIsComplete(model: RequestDetailModel): boolean { + if (model.historyError !== null || !isTerminalStatus(model.request.status)) { + return false; + } + return model.history.some( + (event) => event.type === "status" && event.status === model.request.status, + ); +} + +export interface RequestDetailRefreshState { + terminal: boolean; + transientFailureCount: number; +} + +export function requestDetailRefreshState( + model: RequestDetailModel, +): RequestDetailRefreshState { + const historyError = model.historyError; + return { + terminal: + requestDetailIsComplete(model) || + (isTerminalStatus(model.request.status) && + historyError !== null && + !historyError.retryable), + transientFailureCount: historyError?.retryable ? 1 : 0, + }; +} + +function mapRequestSummary(value: GatewayRequestSummary): RequestSummaryModel | null { + const receivedAtMs = safeInt64ToNumber(value.receivedAtMs); + if (receivedAtMs === null) { + return null; + } + return { + sqid: value.sqid, + queue: value.queue, + changeUris: [...value.changeUris], + receivedAtMs, + status: value.status, + lastError: value.lastError === "" ? null : value.lastError, + metadata: { ...value.metadata }, + }; +} + +function mapHistoryEvent(value: GatewayHistoryEvent): HistoryEventModel | null { + const timestampMs = safeInt64ToNumber(value.timestampMs); + if (timestampMs === null) { + return null; + } + const type = value.type === "status" || value.type === "event" ? value.type : "unknown"; + return { + timestampMs, + type, + status: value.status === "" ? null : value.status, + event: value.event === "" ? null : value.event, + lastError: value.lastError === "" ? null : value.lastError, + metadata: { ...value.metadata }, + }; +} + +function validListInput(input: LoadRequestListInput): boolean { + const pageSize = input.pageSize ?? 50; + return ( + input.queue !== "" && + Number.isSafeInteger(input.receivedAtOrAfterMs) && + Number.isSafeInteger(input.receivedBeforeMs) && + input.receivedAtOrAfterMs < input.receivedBeforeMs && + Number.isInteger(pageSize) && + pageSize > 0 + ); +} + +export async function loadRequestList( + resolveGateway: GatewayResolver, + input: LoadRequestListInput, + options: GatewayLoadOptions = {}, +): Promise> { + if (!validListInput(input)) { + return { ok: false, error: INVALID_INPUT }; + } + try { + const response = await resolveGateway(input.queue).list({ + queue: input.queue, + receivedAtOrAfterMs: BigInt(input.receivedAtOrAfterMs), + receivedBeforeMs: BigInt(input.receivedBeforeMs), + pageSize: input.pageSize ?? 50, + pageToken: input.pageToken ?? "", + }); + const requests: RequestSummaryModel[] = []; + for (const value of response.requests) { + const request = mapRequestSummary(value); + if (request === null) { + return { ok: false, error: INTERNAL_ERROR }; + } + requests.push(request); + } + return { + ok: true, + data: { + queue: input.queue, + receivedAtOrAfterMs: input.receivedAtOrAfterMs, + receivedBeforeMs: input.receivedBeforeMs, + requests, + nextPageToken: response.nextPageToken === "" ? null : response.nextPageToken, + }, + }; + } catch (error) { + reportGatewayError(options, "list", input.queue, null, error); + return { ok: false, error: classifyGatewayError(error) }; + } +} + +export async function loadRequestDetail( + resolveGateway: GatewayResolver, + input: LoadRequestDetailInput, + options: GatewayLoadOptions = {}, +): Promise> { + if (input.queue === "" || input.sqid === "") { + return { ok: false, error: INVALID_INPUT }; + } + try { + const gateway = resolveGateway(input.queue); + const [summaryResult, historyResult] = await Promise.allSettled([ + gateway.getRequestSummaryByID(input), + gateway.getRequestHistoryByID(input), + ]); + if (summaryResult.status === "rejected") { + reportGatewayError(options, "summary", input.queue, input.sqid, summaryResult.reason); + if (historyResult.status === "rejected") { + reportGatewayError(options, "history", input.queue, input.sqid, historyResult.reason); + } + return { ok: false, error: classifyGatewayError(summaryResult.reason) }; + } + if (summaryResult.value.request === undefined) { + return { ok: false, error: INTERNAL_ERROR }; + } + const request = mapRequestSummary(summaryResult.value.request); + if (request === null) { + return { ok: false, error: INTERNAL_ERROR }; + } + if (historyResult.status === "rejected") { + reportGatewayError(options, "history", input.queue, input.sqid, historyResult.reason); + return { + ok: true, + data: { + request, + history: [], + historyError: classifyGatewayError(historyResult.reason), + }, + }; + } + const history: HistoryEventModel[] = []; + for (const value of historyResult.value.events) { + const event = mapHistoryEvent(value); + if (event === null) { + return { + ok: true, + data: { request, history: [], historyError: INTERNAL_ERROR }, + }; + } + history.push(event); + } + return { ok: true, data: { request, history, historyError: null } }; + } catch (error) { + reportGatewayError(options, "summary", input.queue, input.sqid, error); + return { ok: false, error: classifyGatewayError(error) }; + } +} + +export interface GatewayChangeSubmission { + request: GatewayRequestSummary; + version: string; + versionHref: string; +} + +export async function loadChangeSubmissions( + read: () => Promise, + context: Omit, + options: GatewayLoadOptions = {}, +): Promise> { + if (!context.queue) { + return { ok: false, error: INVALID_INPUT }; + } + try { + const submissions: ChangeDetailModel["submissions"] = []; + for (const item of await read()) { + const request = mapRequestSummary(item.request); + if (!request) { + return { ok: false, error: INTERNAL_ERROR }; + } + submissions.push({ request, version: item.version, versionHref: item.versionHref }); + } + return { ok: true, data: { ...context, submissions } }; + } catch (error) { + reportGatewayError(options, "change", context.queue, null, error); + return { ok: false, error: classifyGatewayError(error) }; + } +} diff --git a/web/submitqueue/src/status.test.ts b/web/submitqueue/src/status.test.ts new file mode 100644 index 00000000..8c75e256 --- /dev/null +++ b/web/submitqueue/src/status.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isTerminalStatus, knownRequestStatuses, statusDisplay } from "./status"; + +describe("request status display", () => { + it.each(["landed", "error", "cancelled"])("marks %s terminal", (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(["accepted", "validating", "speculating", "landing", "cancelling"])( + "keeps %s active", + (status) => { + expect(isTerminalStatus(status)).toBe(false); + }, + ); + + it("renders newly-added statuses with a readable fallback", () => { + expect(statusDisplay("waiting_for_owner")).toEqual({ + label: "Waiting For Owner", + tone: "neutral", + terminal: false, + }); + expect(knownRequestStatuses).toContain("landed"); + }); +}); diff --git a/web/submitqueue/src/status.ts b/web/submitqueue/src/status.ts new file mode 100644 index 00000000..4de73d99 --- /dev/null +++ b/web/submitqueue/src/status.ts @@ -0,0 +1,49 @@ +import type { StatusDisplay } from "./models.js"; + +const STATUS_DISPLAY: Readonly> = { + accepting: { label: "Accepting", tone: "progress", terminal: false }, + accepted: { label: "Accepted", tone: "progress", terminal: false }, + started: { label: "Started", tone: "progress", terminal: false }, + validating: { label: "Validating", tone: "progress", terminal: false }, + validated: { label: "Validated", tone: "progress", terminal: false }, + batching: { label: "Batching", tone: "progress", terminal: false }, + batched: { label: "Batched", tone: "progress", terminal: false }, + speculating: { label: "Speculating", tone: "progress", terminal: false }, + speculated: { label: "Speculated", tone: "progress", terminal: false }, + landing: { label: "Landing", tone: "progress", terminal: false }, + landed: { label: "Landed", tone: "success", terminal: true }, + cancelling: { label: "Cancelling", tone: "warning", terminal: false }, + cancelled: { label: "Cancelled", tone: "neutral", terminal: true }, + error: { label: "Error", tone: "danger", terminal: true }, +}; + +const UNKNOWN_STATUS: StatusDisplay = { + label: "Unknown", + tone: "neutral", + terminal: false, +}; + +export const knownRequestStatuses = Object.freeze(Object.keys(STATUS_DISPLAY)); + +export function statusDisplay(status: string): StatusDisplay { + const known = STATUS_DISPLAY[status]; + if (known !== undefined) { + return known; + } + const normalized = status.trim(); + if (normalized === "") { + return UNKNOWN_STATUS; + } + return { + ...UNKNOWN_STATUS, + label: normalized + .split(/[-_\s]+/u) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "), + }; +} + +export function isTerminalStatus(status: string): boolean { + return statusDisplay(status).terminal; +} diff --git a/web/submitqueue/src/test-setup.ts b/web/submitqueue/src/test-setup.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/web/submitqueue/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/submitqueue/src/testing.ts b/web/submitqueue/src/testing.ts new file mode 100644 index 00000000..c9e5f5bd --- /dev/null +++ b/web/submitqueue/src/testing.ts @@ -0,0 +1,75 @@ +import type { + GatewayHistoryEvent, + GatewayReader, + GatewayRequestSummary, +} from "./server.js"; + +const DEFAULT_TIMESTAMP = 1_700_000_000_000n; + +export function gatewayRequestFixture( + overrides: Partial = {}, +): GatewayRequestSummary { + return { + sqid: "demo-queue/1", + queue: "demo-queue", + changeUris: ["github://github.com/uber/submitqueue/pull/1/0123456789012345678901234567890123456789"], + receivedAtMs: DEFAULT_TIMESTAMP, + status: "speculating", + lastError: "", + metadata: {}, + ...overrides, + }; +} + +export function gatewayHistoryFixture( + overrides: Partial = {}, +): GatewayHistoryEvent { + return { + timestampMs: DEFAULT_TIMESTAMP, + status: "started", + lastError: "", + metadata: {}, + type: "status", + event: "", + ...overrides, + }; +} + +export interface FakeGatewayReaderOptions { + requests?: readonly GatewayRequestSummary[]; + nextPageToken?: string; + summary?: GatewayRequestSummary; + history?: readonly GatewayHistoryEvent[]; + listError?: unknown; + summaryError?: unknown; + historyError?: unknown; +} + +export function createFakeGatewayReader(options: FakeGatewayReaderOptions = {}): GatewayReader { + const summary = options.summary ?? options.requests?.[0] ?? gatewayRequestFixture(); + return { + async list() { + if (options.listError !== undefined) { + throw options.listError; + } + return { + requests: options.requests ?? [summary], + nextPageToken: options.nextPageToken ?? "", + }; + }, + async getRequestSummaryByID() { + if (options.summaryError !== undefined) { + throw options.summaryError; + } + return { request: summary }; + }, + async getRequestHistoryByID() { + if (options.historyError !== undefined) { + throw options.historyError; + } + return { events: options.history ?? [gatewayHistoryFixture()] }; + }, + }; +} + +export type { GatewayHistoryEvent, GatewayReader, GatewayRequestSummary } from "./server.js"; diff --git a/web/submitqueue/test/server-only.ts b/web/submitqueue/test/server-only.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/web/submitqueue/test/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/web/submitqueue/tsconfig.build.json b/web/submitqueue/tsconfig.build.json new file mode 100644 index 00000000..7c9bee23 --- /dev/null +++ b/web/submitqueue/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test-setup.ts"] +} diff --git a/web/submitqueue/tsconfig.json b/web/submitqueue/tsconfig.json new file mode 100644 index 00000000..70a7e1dd --- /dev/null +++ b/web/submitqueue/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"] +} diff --git a/web/submitqueue/vitest.config.ts b/web/submitqueue/vitest.config.ts new file mode 100644 index 00000000..14acb2a4 --- /dev/null +++ b/web/submitqueue/vitest.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "server-only": path.resolve(import.meta.dirname, "test/server-only.ts"), + }, + }, + esbuild: { + jsx: "automatic", + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test-setup.ts"], + }, +});