diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md index cdc21782d..aab9de2e5 100644 --- a/doc/rfc/web-ui.md +++ b/doc/rfc/web-ui.md @@ -70,6 +70,8 @@ Summary is the default request view, and `?view=history` makes History shareable **Theme.** Views use BaseUI components with host-supplied `BaseProvider` and Styletron `Provider`. BaseUI owns layout, responsive grids, colors and typography; the stylesheet only enables wrapping long identifiers. There is no independent library theme or styling engine. Hosts choose light, dark, custom, or system-driven themes and own SSR extraction and client hydration. The reference Next.js hosts share a request-scoped Styletron registry that streams new styles and hydrates them in the browser. BaseUI and Styletron React are peers alongside React and React DOM; React 18 and 19 are both supported. +**Packaging.** Hosts process the ESM UI entries with their bundler for SSR and the browser. BaseUI imports use package entry points, such as `baseui/block`, so the host can supply its own BaseUI package layout. The separate `/server` entries run directly in Node and do not load BaseUI. Package consumer tests exercise direct Node page loading and UI rendering and hydration through Vite. + ## Observability and identity Metrics and tracing use injected OpenTelemetry meters and tracers, defaulting to the API's no-op providers. Logging uses a synchronous `onLog` callback passed with each page request: severity, message, and structured metadata, including the request ID and the original error under `error`. The host binds the callback to its request-scoped logger; the libraries have no logging-library dependency. Omitting the callback is silent, and callback exceptions cannot change page outcomes. Every gateway read and page load is timed and traced. The reference hosts write Pino JSON logs, serializing errors under `err`, and SubmitQueue exposes Prometheus metrics only when a port is configured. The reference host registers no tracer provider, so its spans are no-ops; a host that registers OpenTelemetry tracing can also add a gateway interceptor that propagates trace context. diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 9dc880121..a179be720 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -35,8 +35,10 @@ test_suite( "//web/test/e2e/submitqueue:typecheck_typecheck_test", "//web/test/package:consumer_node_typecheck_typecheck_test", "//web/test/package:consumer_runtime_test", + "//web/test/package:consumer_server_runtime_test", "//web/test/package:consumer_typecheck_typecheck_test", "//web/test/package:pack_contents_test", + "//web/test/package:platform_pack_contents_test", "//web/test/package:stovepipe_pack_contents_test", "//web/test/package/react18:react18_runtime_test", "//web/test/testutil:compose_test", diff --git a/web/README.md b/web/README.md index d4c9f25d5..bbf8773be 100644 --- a/web/README.md +++ b/web/README.md @@ -12,6 +12,8 @@ submitqueue the SubmitQueue module: routes, controllers, pages, styles Dependencies only point down. The module never imports generated protos, a transport, or a web framework; only the host chooses implementations. A package test checks this against every file in the built package. +UI entry points are ESM for the host's bundler, including server-side rendering. They import BaseUI through package entry points such as `baseui/block`, allowing hosts to supply BaseUI with their own package layout. The separate `/server` entry points run directly in Node without loading the UI or BaseUI. + ## How a page is served 1. Next.js routes every path to one catch-all page in the host. diff --git a/web/platform/auto-refresh.tsx b/web/platform/auto-refresh.tsx index 3818fd28f..028652eac 100644 --- a/web/platform/auto-refresh.tsx +++ b/web/platform/auto-refresh.tsx @@ -1,9 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Block } from "baseui/block/index.js"; -import { Button, SIZE } from "baseui/button/index.js"; -import { LabelXSmall } from "baseui/typography/index.js"; +import { Block } from "baseui/block"; +import { Button, SIZE } from "baseui/button"; +import { LabelXSmall } from "baseui/typography"; export interface AutoRefreshProps { terminal?: boolean; diff --git a/web/platform/facts.tsx b/web/platform/facts.tsx index 5144002fd..bf2bcfc99 100644 --- a/web/platform/facts.tsx +++ b/web/platform/facts.tsx @@ -1,8 +1,8 @@ "use client"; import type { ReactNode } from "react"; -import { styled } from "baseui/index.js"; -import { LabelXSmall, ParagraphSmall } from "baseui/typography/index.js"; +import { styled } from "baseui"; +import { LabelXSmall, ParagraphSmall } from "baseui/typography"; const FactRow = styled("div", ({ $theme }) => ({ paddingTop: $theme.sizing.scale500, diff --git a/web/platform/table.tsx b/web/platform/table.tsx index 41e3b1efd..e6abe2735 100644 --- a/web/platform/table.tsx +++ b/web/platform/table.tsx @@ -7,7 +7,7 @@ import { StyledTableHead, StyledTableHeadCell, StyledTableHeadRow, -} from "baseui/table-semantic/index.js"; +} from "baseui/table-semantic"; import type { ReactNode } from "react"; export function Table({ label, headers, children }: { diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 654c08dc1..671aa4aa7 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -6,6 +6,8 @@ Logging uses a synchronous `onLog` callback passed on each `handle` request. Bin ## Integration +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). + Instrumentation belongs in the host: wrap `handle` to observe page results and instrument the concrete service client for RPC traces. A `render` result can contain a failed section, so check `requests.ok`, or both `history.ok` and `projectStatus.ok`, before reporting success. The reference host uses native OpenTelemetry spans, a page-load counter and a duration metric; other hosts can use their own telemetry without an adapter. The reusable module has no telemetry dependency or instrumentation API. Use the server entry's `createStovepipeWeb` to load a page and render its model with `StovepipeApp`. Mount beneath `WebNavigationProvider` when the host supplies client navigation and refresh. Without it, links and refresh use ordinary document navigation. Import `@submitqueue/web-stovepipe/styles.css` for long-identifier wrapping, and supply BaseUI's `BaseProvider` and Styletron's `Provider`. The host owns the theme and styling engine, including SSR stylesheet extraction and hydration; the library creates neither. BaseUI, React, React DOM, and Styletron React are peer dependencies. diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index 28fc98d35..7d785ab03 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -1,9 +1,9 @@ "use client"; import { Fact, Timestamp } from "@submitqueue/web-platform"; -import { Block } from "baseui/block/index.js"; -import { Tag } from "baseui/tag/index.js"; -import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography/index.js"; +import { Block } from "baseui/block"; +import { Tag } from "baseui/tag"; +import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography"; import { RefreshControls } from "./refresh.js"; import type { StovepipePageModel } from "../entity/index.js"; import { Link, Section } from "./components.js"; diff --git a/web/stovepipe/view/components.tsx b/web/stovepipe/view/components.tsx index 0736b1cc8..a708dae56 100644 --- a/web/stovepipe/view/components.tsx +++ b/web/stovepipe/view/components.tsx @@ -3,9 +3,9 @@ import { useState, type ReactNode } from "react"; import { useWebNavigation, type WebLinkProps } from "@submitqueue/web-platform"; import type { ReadResult } from "../entity/index.js"; -import { Notification, KIND } from "baseui/notification/index.js"; -import { StyledLink } from "baseui/link/index.js"; -import { ParagraphSmall } from "baseui/typography/index.js"; +import { Notification, KIND } from "baseui/notification"; +import { StyledLink } from "baseui/link"; +import { ParagraphSmall } from "baseui/typography"; export { Table } from "@submitqueue/web-platform"; diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx index 4845ac0eb..933c59566 100644 --- a/web/stovepipe/view/pages.tsx +++ b/web/stovepipe/view/pages.tsx @@ -1,10 +1,10 @@ "use client"; import { Fact, Timestamp } from "@submitqueue/web-platform"; -import { Block } from "baseui/block/index.js"; -import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic/index.js"; -import { Tag } from "baseui/tag/index.js"; -import { ParagraphSmall } from "baseui/typography/index.js"; +import { Block } from "baseui/block"; +import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; +import { Tag } from "baseui/tag"; +import { ParagraphSmall } from "baseui/typography"; import type { RequestSummaryModel, HistoryEventModel, diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index 2ca58594c..fca867e21 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -1,10 +1,10 @@ "use client"; import { Timestamp } from "@submitqueue/web-platform"; -import { Block } from "baseui/block/index.js"; -import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic/index.js"; -import { Tag } from "baseui/tag/index.js"; -import { ParagraphSmall } from "baseui/typography/index.js"; +import { Block } from "baseui/block"; +import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; +import { Tag } from "baseui/tag"; +import { ParagraphSmall } from "baseui/typography"; import type { ProjectStatusModel } from "../entity/index.js"; import { Link, Table } from "./components.js"; diff --git a/web/stovepipe/view/refresh.tsx b/web/stovepipe/view/refresh.tsx index a23ac0ccc..a5949e096 100644 --- a/web/stovepipe/view/refresh.tsx +++ b/web/stovepipe/view/refresh.tsx @@ -2,8 +2,8 @@ import { useCallback, useState } from "react"; import { AutoRefresh, useWebNavigation } from "@submitqueue/web-platform"; -import { Checkbox } from "baseui/checkbox/index.js"; -import { ParagraphXSmall } from "baseui/typography/index.js"; +import { Checkbox } from "baseui/checkbox"; +import { ParagraphXSmall } from "baseui/typography"; export function RefreshControls() { const navigation = useWebNavigation(); diff --git a/web/stovepipe/vitest.config.ts b/web/stovepipe/vitest.config.ts index 177f92ec7..1f860d68b 100644 --- a/web/stovepipe/vitest.config.ts +++ b/web/stovepipe/vitest.config.ts @@ -9,5 +9,10 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./test-setup.ts"], + server: { + deps: { + inline: ["@submitqueue/web-platform"], + }, + }, }, }); diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md index e99b612b9..5a8264e68 100644 --- a/web/submitqueue/README.md +++ b/web/submitqueue/README.md @@ -13,6 +13,8 @@ The complete read-only SubmitQueue UI as a mountable module. Folders follow the Entry points: the root is client-safe (`SubmitQueueApp`, page frames, `WebNavigationProvider`, model types); `./server` has `createSubmitQueueWeb`, the gateway contract, and `WebPaths`; `./extension/gateway/mock` has test fakes; `./styles.css` is the full stylesheet. Controllers and helpers stay internal. +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. 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. diff --git a/web/submitqueue/view/components.tsx b/web/submitqueue/view/components.tsx index 7cd705be1..e82a0a397 100644 --- a/web/submitqueue/view/components.tsx +++ b/web/submitqueue/view/components.tsx @@ -3,15 +3,15 @@ import { ErrorState } from "./error-state.js"; import { useLastSuccessfulData } from "./last-successful-data.js"; import { Fact, Table, Timestamp } from "@submitqueue/web-platform"; -import { styled } from "baseui/index.js"; -import { Block } from "baseui/block/index.js"; -import { Button, SIZE } from "baseui/button/index.js"; -import { Input } from "baseui/input/index.js"; -import { KIND, Notification } from "baseui/notification/index.js"; -import { Select, StyledInput } from "baseui/select/index.js"; -import { Tag } from "baseui/tag/index.js"; -import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic/index.js"; -import { HeadingLarge, HeadingSmall, LabelXSmall, ParagraphSmall, ParagraphXSmall } from "baseui/typography/index.js"; +import { styled } from "baseui"; +import { Block } from "baseui/block"; +import { Button, SIZE } from "baseui/button"; +import { Input } from "baseui/input"; +import { KIND, Notification } from "baseui/notification"; +import { Select, StyledInput } from "baseui/select"; +import { Tag } from "baseui/tag"; +import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; +import { HeadingLarge, HeadingSmall, LabelXSmall, ParagraphSmall, ParagraphXSmall } from "baseui/typography"; import { useWebApp, WebLink } from "./web-app.js"; import { forwardRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react"; import type { diff --git a/web/submitqueue/view/error-state.tsx b/web/submitqueue/view/error-state.tsx index d42f9bf29..ea5ed88dc 100644 --- a/web/submitqueue/view/error-state.tsx +++ b/web/submitqueue/view/error-state.tsx @@ -1,8 +1,8 @@ "use client"; import type { WebError } from "../entity/index.js"; -import { KIND, Notification } from "baseui/notification/index.js"; -import { HeadingSmall, ParagraphSmall } from "baseui/typography/index.js"; +import { KIND, Notification } from "baseui/notification"; +import { HeadingSmall, ParagraphSmall } from "baseui/typography"; export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) { return JSON.stringify(result.model)); -const engine = new Server(); -const html = renderToStaticMarkup(createElement(Provider, { value: engine }, - createElement(BaseProvider, { theme: LightTheme }, - createElement(SubmitQueueShell, { homeHref: "/queues" }, - 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"')); -const stylesheet = await readFile(new URL(import.meta.resolve("@submitqueue/web-submitqueue/styles.css")), "utf8"); -assert.ok(stylesheet.includes("@submitqueue/web-platform/styles.css")); -const commonStyles = await readFile(new URL(import.meta.resolve("@submitqueue/web-platform/styles.css")), "utf8"); -assert.ok(commonStyles.trim().length > 0); -console.log("An ordinary Node/React host loaded and rendered the application with its packaged stylesheet."); diff --git a/web/test/package/consumer-runtime.test.mjs b/web/test/package/consumer-runtime.test.mjs new file mode 100644 index 000000000..13879cc70 --- /dev/null +++ b/web/test/package/consumer-runtime.test.mjs @@ -0,0 +1,34 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createElement } from "react"; +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 { createSubmitQueueWeb, WebPaths } from "@submitqueue/web-submitqueue/server"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; + +test("renders a packaged application with its host providers", async () => { + const web = createSubmitQueueWeb({ + gateway: createFakeGatewayReader(), + paths: new WebPaths({ basePath: "/queues" }), + }); + const result = await web.handle({ path: "/queues/demo-queue", search: {} }); + assert.equal(result.kind, "render"); + assert.equal(result.model.result.ok, true); + assert.doesNotThrow(() => JSON.stringify(result.model)); + const engine = new Server(); + const html = renderToStaticMarkup(createElement(Provider, { value: engine }, + createElement(BaseProvider, { theme: LightTheme }, + createElement(SubmitQueueShell, { homeHref: "/queues" }, + 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"')); + const stylesheet = await readFile(new URL(import.meta.resolve("@submitqueue/web-submitqueue/styles.css")), "utf8"); + assert.ok(stylesheet.includes("@submitqueue/web-platform/styles.css")); + const commonStyles = await readFile(new URL(import.meta.resolve("@submitqueue/web-platform/styles.css")), "utf8"); + assert.ok(commonStyles.trim().length > 0); +}); diff --git a/web/test/package/consumer-server-runtime.mjs b/web/test/package/consumer-server-runtime.mjs new file mode 100644 index 000000000..4e0b1e6fa --- /dev/null +++ b/web/test/package/consumer-server-runtime.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; +import { createStovepipeWeb } from "@submitqueue/web-stovepipe/server"; + +const submitqueue = createSubmitQueueWeb({ gateway: createFakeGatewayReader() }); +const requests = await submitqueue.handle({ path: "/demo-queue", search: {} }); +assert.equal(requests.kind, "render"); +assert.equal(requests.model.result.ok, true); +assert.doesNotThrow(() => JSON.stringify(requests.model)); + +const stovepipe = createStovepipeWeb({ + queues: [{ name: "demo-queue" }], + service: { + list: async () => ({ requests: [], nextPageToken: "" }), + getRequestHistoryByID: async () => { throw new Error("unexpected history lookup"); }, + getProjectStatusByID: async () => { throw new Error("unexpected project lookup"); }, + }, +}); +const queue = await stovepipe.handle({ path: "/demo-queue", search: new URLSearchParams() }); +assert.equal(queue.kind, "render"); +assert.equal(queue.model.requests.ok, true); +assert.deepEqual(queue.model.requests.data.requests, []); +assert.doesNotThrow(() => JSON.stringify(queue.model)); diff --git a/web/test/package/pack_contents_test.py b/web/test/package/pack_contents_test.py index f24967206..57077aca8 100644 --- a/web/test/package/pack_contents_test.py +++ b/web/test/package/pack_contents_test.py @@ -63,13 +63,15 @@ def main() -> int: raise RuntimeError("expected the module package archive") profile = sys.argv[2] if len(sys.argv) == 3 else "submitqueue" - if profile not in ("submitqueue", "stovepipe"): + if profile not in ("platform", "submitqueue", "stovepipe"): raise RuntimeError(f"unknown package profile: {profile}") with tarfile.open(Path(sys.argv[1])) as archive: - expected = _EXPECTED_FILES if profile == "submitqueue" else { - "package/dist/index.d.ts", "package/dist/server.d.ts", "package/README.md", - } + expected = { + "platform": {"package/dist/index.d.ts", "package/styles.css"}, + "submitqueue": _EXPECTED_FILES, + "stovepipe": {"package/dist/index.d.ts", "package/dist/server.d.ts", "package/README.md"}, + }[profile] missing = sorted(expected - {member.name for member in archive.getmembers()}) if missing: print(f"The package is missing: {', '.join(missing)}", file=sys.stderr) @@ -83,7 +85,8 @@ def main() -> int: print("The core module must not require framework, transport, logging, or telemetry-backend dependencies.", file=sys.stderr) return 1 stylesheet = package.get("exports", {}).get("./styles.css") - if stylesheet != "./view/styles.css" or stylesheet not in package.get("sideEffects", []): + expected_stylesheet = "./styles.css" if profile == "platform" else "./view/styles.css" + if stylesheet != expected_stylesheet or stylesheet not in package.get("sideEffects", []): print("The module must export and preserve its default stylesheet.", file=sys.stderr) return 1 if violations := _client_entry_violations(archive): @@ -93,6 +96,9 @@ def main() -> int: if member.name.endswith((".js", ".d.ts")): source = archive.extractfile(member).read().decode("utf-8") for specifier in _IMPORT_PATTERN.findall(source): + if specifier.startswith("baseui/") and specifier.endswith("/index.js"): + print(f"{member.name} imports {specifier}; use BaseUI's package entrypoints so hosts can supply their own package layout.", file=sys.stderr) + return 1 if _imports_forbidden(specifier): print(f"{member.name} imports {specifier}, which the host owns.", file=sys.stderr) return 1 diff --git a/web/test/package/react18/BUILD.bazel b/web/test/package/react18/BUILD.bazel index e02e51008..7a32ef68c 100644 --- a/web/test/package/react18/BUILD.bazel +++ b/web/test/package/react18/BUILD.bazel @@ -1,6 +1,6 @@ -load("@aspect_rules_js//js:defs.bzl", "js_test") load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web:vitest/package_json.bzl", vitest = "bin") npm_link_all_packages(name = "node_modules") @@ -18,15 +18,19 @@ npm_link_all_packages(name = "node_modules") # Resolves react from this package's React 18 links; the workspace root's # React 19 must stay out of the runfiles or Node would find it first. -js_test( +vitest.vitest_test( name = "react18_runtime_test", + args = ["run", "--config", "../vitest.config.mjs"], + chdir = "web/test/package/react18", data = [ + "react18-runtime.test.mjs", ":node_modules", ":node_modules/@submitqueue/web-platform", ":node_modules/@submitqueue/web-stovepipe", ":node_modules/@submitqueue/web-submitqueue", "//web:node_modules/@bufbuild/protobuf", "//web:node_modules/@connectrpc/connect", + "//web:node_modules/vitest", + "//web/test/package:vitest_config", ], - entry_point = "react18-runtime.mjs", ) diff --git a/web/test/package/react18/react18-runtime.mjs b/web/test/package/react18/react18-runtime.mjs deleted file mode 100644 index 27b0413d1..000000000 --- a/web/test/package/react18/react18-runtime.mjs +++ /dev/null @@ -1,123 +0,0 @@ -import assert from "node:assert/strict"; -import { JSDOM } from "jsdom"; -import React, { createElement } from "react"; -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 { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; -import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; - -import { StovepipeApp } from "@submitqueue/web-stovepipe"; -import { createStovepipeWeb } from "@submitqueue/web-stovepipe/server"; - -assert.match(React.version, /^18\./u); - -const web = createSubmitQueueWeb({ - gateway: createFakeGatewayReader(), -}); -const routes = [ - { path: "/", search: {} }, - { path: "/demo-queue", search: {} }, - { path: "/demo-queue/request/demo-queue/1", search: {} }, - { path: "/demo-queue/request/demo-queue/1", search: { view: "history" } }, -]; -const pages = []; -const themes = [LightTheme, DarkTheme, createTheme({ colors: { - backgroundPrimary: "#102030", contentPrimary: "#ffffff", - buttonPrimaryFill: "#123456", buttonPrimaryText: "#ffffff", -} })]; -function withProviders(page, engine, theme) { - return createElement(Provider, { value: engine }, createElement(BaseProvider, { theme }, page)); -} -function addPage(route, page) { - for (const theme of themes) { - const engine = new Server(); - const html = renderToString(withProviders(page, engine, theme)); - assert.ok(engine.getCss().length > 0); - pages.push({ route, page, theme, html, stylesheets: engine.getStylesheets() }); - } -} -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 })); - addPage(route, page); -} - -const stovepipe = createStovepipeWeb({ queues: [{ name: "repo/main" }], service: { - list: async () => ({ requests: [], nextPageToken: "" }), - getProjectStatusByID: async () => ({ - queue: "repo/main", requestId: "1", updatedAtMs: 100n, - repositoryResult: { case: "repositoryBreakageDegree", value: 0.25 }, - projectResultsComplete: true, nextPageToken: "", - projects: [{ project: "example-project", result: { case: "breakageDegree", value: 0 } }], - }), - getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "" }, events: [] }), -} }); -for (const path of ["/repo%2Fmain", "/repo%2Fmain/request/1"]) { - const result = await stovepipe.handle({ path, search: new URLSearchParams() }); - assert.equal(result.kind, "render"); - const page = createElement(StovepipeApp, { model: result.model }); - addPage(path, page); -} - -const dom = new JSDOM("", { url: "https://submitqueue.test/" }); -for (const name of ["window", "document", "navigator"]) { - Object.defineProperty(globalThis, name, { configurable: true, value: name === "window" ? dom.window : dom.window[name] }); -} -const errors = []; -const reportError = console.error; -console.error = (...values) => { - const message = values.map(String).join(" "); - // BaseUI's Popover still uses defaultProps; React 18 warns but hydrates correctly. - if (message.includes("Popover") && message.includes("Support for defaultProps will be removed from function components")) { - reportError(...values); - } else { - errors.push(message); - } -}; -const { hydrateRoot } = await import("react-dom/client"); -// act flushes hydration before unmounting; unmounting a root that is still -// waiting to hydrate is itself reported as a hydration failure. -const { act } = React; -globalThis.IS_REACT_ACT_ENVIRONMENT = true; -for (const { route, page, theme, html, stylesheets } of pages) { - const styles = stylesheets.map(({ css, attrs }) => { - const style = document.createElement("style"); - for (const [name, value] of Object.entries(attrs)) style.setAttribute(name, value); - style.textContent = css; - document.head.append(style); - return style; - }); - const engine = new Client({ hydrate: styles }); - const container = document.createElement("div"); - container.innerHTML = html; - document.body.append(container); - let root; - await act(async () => { - root = hydrateRoot(container, withProviders(page, engine, theme)); - }); - const expected = document.createElement("div"); - expected.style.backgroundColor = theme.colors.backgroundPrimary; - 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.remove(); - if (route === "/repo%2Fmain/request/1") { - assert.match(container.textContent, /example-project/u); - assert.match(container.textContent, /Repository resultBreakage/u); - assert.match(container.textContent, /example-projectGreen/u); - assert.doesNotMatch(container.textContent, /%/u); - } - await act(async () => root.unmount()); - container.remove(); - for (const style of styles) style.remove(); - assert.deepEqual(errors, [], `${JSON.stringify(route)} failed to hydrate on React ${React.version}`); -} -console.error = reportError; -console.log(`React ${React.version} rendered and hydrated ${pages.length} packaged pages.`); diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs new file mode 100644 index 000000000..677170500 --- /dev/null +++ b/web/test/package/react18/react18-runtime.test.mjs @@ -0,0 +1,126 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import React, { createElement } from "react"; +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 { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; + +import { StovepipeApp } from "@submitqueue/web-stovepipe"; +import { createStovepipeWeb } from "@submitqueue/web-stovepipe/server"; + +test("renders and hydrates packaged pages with React 18 and host themes", async () => { + assert.match(React.version, /^18\./u); + + const web = createSubmitQueueWeb({ + gateway: createFakeGatewayReader(), + }); + const routes = [ + { path: "/", search: {} }, + { path: "/demo-queue", search: {} }, + { path: "/demo-queue/request/demo-queue/1", search: {} }, + { path: "/demo-queue/request/demo-queue/1", search: { view: "history" } }, + ]; + const pages = []; + const themes = [LightTheme, DarkTheme, createTheme({ colors: { + backgroundPrimary: "#102030", contentPrimary: "#ffffff", + buttonPrimaryFill: "#123456", buttonPrimaryText: "#ffffff", + } })]; + function withProviders(page, engine, theme) { + return createElement(Provider, { value: engine }, createElement(BaseProvider, { theme }, page)); + } + function addPage(route, page) { + for (const theme of themes) { + const engine = new Server(); + const html = renderToString(withProviders(page, engine, theme)); + assert.ok(engine.getCss().length > 0); + pages.push({ route, page, theme, html, stylesheets: engine.getStylesheets() }); + } + } + 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 })); + addPage(route, page); + } + + const stovepipe = createStovepipeWeb({ queues: [{ name: "repo/main" }], service: { + list: async () => ({ requests: [], nextPageToken: "" }), + getProjectStatusByID: async () => ({ + queue: "repo/main", requestId: "1", updatedAtMs: 100n, + repositoryResult: { case: "repositoryBreakageDegree", value: 0.25 }, + projectResultsComplete: true, nextPageToken: "", + projects: [{ project: "example-project", result: { case: "breakageDegree", value: 0 } }], + }), + getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "" }, events: [] }), + } }); + for (const path of ["/repo%2Fmain", "/repo%2Fmain/request/1"]) { + const result = await stovepipe.handle({ path, search: new URLSearchParams() }); + assert.equal(result.kind, "render"); + const page = createElement(StovepipeApp, { model: result.model }); + addPage(path, page); + } + + const dom = new JSDOM("", { url: "https://submitqueue.test/" }); + for (const name of ["window", "document", "navigator"]) { + Object.defineProperty(globalThis, name, { configurable: true, value: name === "window" ? dom.window : dom.window[name] }); + } + const errors = []; + const reportError = console.error; + console.error = (...values) => { + const message = values.map(String).join(" "); + // BaseUI's Popover still uses defaultProps; React 18 warns but hydrates correctly. + if (message.includes("Popover") && message.includes("Support for defaultProps will be removed from function components")) { + reportError(...values); + } else { + errors.push(message); + } + }; + const { hydrateRoot } = await import("react-dom/client"); + // act flushes hydration before unmounting; unmounting a root that is still + // waiting to hydrate is itself reported as a hydration failure. + const { act } = React; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + for (const { route, page, theme, html, stylesheets } of pages) { + const styles = stylesheets.map(({ css, attrs }) => { + const style = document.createElement("style"); + for (const [name, value] of Object.entries(attrs)) style.setAttribute(name, value); + style.textContent = css; + document.head.append(style); + return style; + }); + const engine = new Client({ hydrate: styles }); + const container = document.createElement("div"); + container.innerHTML = html; + document.body.append(container); + let root; + await act(async () => { + root = hydrateRoot(container, withProviders(page, engine, theme)); + }); + const expected = document.createElement("div"); + expected.style.backgroundColor = theme.colors.backgroundPrimary; + 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.remove(); + if (route === "/repo%2Fmain/request/1") { + assert.match(container.textContent, /example-project/u); + assert.match(container.textContent, /Repository resultBreakage/u); + assert.match(container.textContent, /example-projectGreen/u); + assert.doesNotMatch(container.textContent, /%/u); + } + await act(async () => root.unmount()); + container.remove(); + for (const style of styles) style.remove(); + assert.deepEqual(errors, [], `${JSON.stringify(route)} failed to hydrate on React ${React.version}`); + } + console.error = reportError; + console.log(`React ${React.version} rendered and hydrated ${pages.length} packaged pages.`); +}); diff --git a/web/test/package/vitest.config.mjs b/web/test/package/vitest.config.mjs new file mode 100644 index 000000000..fd9d30b32 --- /dev/null +++ b/web/test/package/vitest.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*-runtime.test.mjs"], + server: { + deps: { + inline: [/@submitqueue\/web-/, /(?:^|\/)baseui(?:\/|$)/], + }, + }, + }, +});