From 972f9eaf157058b6aa6a2d985f8fb8fb84d29166 Mon Sep 17 00:00:00 2001 From: Dan Krusi Date: Thu, 5 Nov 2026 10:47:44 +0100 Subject: [PATCH 1/2] Add tests for uncovered web UI pages; fix locale-dependent editor test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover AccountPage, AdminChannelsPage, AdminEnvironmentProvidersPage, AdminUniversesPage, BotCreatePage, GeneralSettingsPage, and HomeRedirect with jsdom component tests in the existing createRoot/act style. Make the session config editor capability test locale-independent: the editor formats token counts with toLocaleString, so the expectation does too. It failed on machines whose locale does not group digits with commas (e.g. en-CH renders 1’000’000). --- .../session/session-config-editor.test.ts | 3 +- platform/web/src/pages/AccountPage.test.tsx | 143 ++++++++++ .../web/src/pages/AdminChannelsPage.test.tsx | 110 ++++++++ .../AdminEnvironmentProvidersPage.test.tsx | 217 +++++++++++++++ .../web/src/pages/AdminUniversesPage.test.tsx | 192 +++++++++++++ platform/web/src/pages/BotCreatePage.test.tsx | 263 ++++++++++++++++++ .../src/pages/GeneralSettingsPage.test.tsx | 135 +++++++++ platform/web/src/pages/HomeRedirect.test.tsx | 95 +++++++ 8 files changed, 1157 insertions(+), 1 deletion(-) create mode 100644 platform/web/src/pages/AccountPage.test.tsx create mode 100644 platform/web/src/pages/AdminChannelsPage.test.tsx create mode 100644 platform/web/src/pages/AdminEnvironmentProvidersPage.test.tsx create mode 100644 platform/web/src/pages/AdminUniversesPage.test.tsx create mode 100644 platform/web/src/pages/BotCreatePage.test.tsx create mode 100644 platform/web/src/pages/GeneralSettingsPage.test.tsx create mode 100644 platform/web/src/pages/HomeRedirect.test.tsx diff --git a/platform/web/src/components/session/session-config-editor.test.ts b/platform/web/src/components/session/session-config-editor.test.ts index c5cb3b6e..c5021be9 100644 --- a/platform/web/src/components/session/session-config-editor.test.ts +++ b/platform/web/src/components/session/session-config-editor.test.ts @@ -157,7 +157,8 @@ describe("OpenAI processing tier config", () => { models: [model], })); const modelId = html.indexOf("anthropic · anthropic:messages"); - const capabilities = html.indexOf("1,000,000 input tokens"); + // The editor formats with the runtime locale, so the expectation does too. + const capabilities = html.indexOf(`${(1_000_000).toLocaleString()} input tokens`); const reasoning = html.indexOf("Reasoning effort"); expect(modelId).toBeGreaterThanOrEqual(0); diff --git a/platform/web/src/pages/AccountPage.test.tsx b/platform/web/src/pages/AccountPage.test.tsx new file mode 100644 index 00000000..e152cf2b --- /dev/null +++ b/platform/web/src/pages/AccountPage.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { LoginConfig, SessionUser } from "@/auth"; +import { ThemeProvider } from "@/lib/theme"; +import { AccountPage } from "./AccountPage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ + updateUser: vi.fn(), + changePassword: vi.fn(), + config: { sso: false, autoSignIn: false, providerId: null, password: "local" } as LoginConfig, +})); +vi.mock("@/auth", () => ({ + authClient: { updateUser: mocks.updateUser, changePassword: mocks.changePassword }, + useLoginConfig: () => ({ data: mocks.config }), +})); + +const local: SessionUser = { id: "user", name: "Ada", email: "ada@example.test", identitySource: "local" }; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", (query: string) => ({ matches: false, media: query, addEventListener: () => undefined, removeEventListener: () => undefined })); + localStorage.clear(); + mocks.config = { sso: false, autoSignIn: false, providerId: null, password: "local" }; + mocks.updateUser.mockReset().mockResolvedValue({ error: null }); + mocks.changePassword.mockReset().mockResolvedValue({ error: null }); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + localStorage.clear(); + document.documentElement.classList.remove("dark"); + vi.unstubAllGlobals(); +}); + +async function show(user: SessionUser = local) { + await act(async () => root.render( + , + )); +} +const input = (id: string) => container.querySelector(`#${id}`); +const button = (label: string) => [...container.querySelectorAll("button")].find((node) => node.textContent?.trim() === label); +async function type(id: string, value: string) { + const node = input(id)!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const submit = (card: string) => act(async () => container.querySelector(`#${card} form`)!.requestSubmit()); + +it("saves a trimmed name only once it differs from the current one", async () => { + await show(); + expect(input("account-email")!.disabled).toBe(true); + expect(button("Save")!.disabled).toBe(true); + await type("account-name", " Ada "); + expect(button("Save")!.disabled).toBe(true); + await type("account-name", " Ada Lovelace "); + expect(button("Save")!.disabled).toBe(false); + await submit("profile"); + await waitForUi(() => expect(container.querySelector("#profile")!.textContent).toContain("Saved.")); + expect(mocks.updateUser).toHaveBeenCalledWith({ name: "Ada Lovelace" }); +}); + +it("shows the reason a profile update was refused", async () => { + mocks.updateUser.mockResolvedValue({ error: { message: "name is reserved" } }); + await show(); + await type("account-name", "Root"); + await submit("profile"); + await waitForUi(() => expect(container.querySelector("#profile")!.textContent).toContain("name is reserved")); + expect(container.querySelector("#profile")!.textContent).not.toContain("Saved."); +}); + +it("leaves a company-managed profile read-only and hides the password form", async () => { + await show({ ...local, identitySource: "company" }); + expect(input("account-name")!.disabled).toBe(true); + expect(button("Save")!.disabled).toBe(true); + expect(container.textContent).toContain("managed by your company"); + expect(container.querySelector("#security")).toBeNull(); +}); + +it("refuses mismatched new passwords without calling the server", async () => { + await show(); + await type("current-password", "old-password"); + await type("new-password", "new-password-1"); + await type("confirm-password", "new-password-2"); + await submit("security"); + expect(container.querySelector("#security")!.textContent).toContain("New passwords do not match."); + expect(mocks.changePassword).not.toHaveBeenCalled(); +}); + +it("changes the password, signs out other sessions, and clears the form", async () => { + await show(); + await type("current-password", "old-password"); + await type("new-password", "new-password-1"); + await type("confirm-password", "new-password-1"); + await submit("security"); + await waitForUi(() => expect(container.querySelector("#security")!.textContent).toContain("Password changed.")); + expect(mocks.changePassword).toHaveBeenCalledWith({ currentPassword: "old-password", newPassword: "new-password-1", revokeOtherSessions: true }); + expect([input("current-password")!.value, input("new-password")!.value, input("confirm-password")!.value]).toEqual(["", "", ""]); +}); + +it("keeps a refused password change on the form with its reason", async () => { + mocks.changePassword.mockResolvedValue({ error: { message: "invalid password" } }); + await show(); + await type("current-password", "wrong"); + await type("new-password", "new-password-1"); + await type("confirm-password", "new-password-1"); + await submit("security"); + await waitForUi(() => expect(container.querySelector("#security")!.textContent).toContain("invalid password")); + expect(container.querySelector("#security")!.textContent).not.toContain("Password changed."); + expect(input("current-password")!.value).toBe("wrong"); +}); + +it.each([ + { name: "password sign-in is off", config: { password: "off" }, user: local, shown: false }, + { name: "SSO is on", config: { sso: true, password: "break-glass" }, user: local, shown: false }, + { name: "SSO is on for an emergency admin", config: { sso: true, password: "break-glass" }, user: { ...local, emergencyAdmin: true }, shown: true }, +] as const)("offers the password form only where it applies: $name", async ({ config, user, shown }) => { + mocks.config = { ...mocks.config, ...config }; + await show(user); + expect(container.querySelector("#security") !== null).toBe(shown); +}); + +it("remembers the chosen theme and applies it", async () => { + await show(); + await act(async () => button("Dark")!.click()); + expect(localStorage.getItem("lightspeed:theme")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + await act(async () => button("Light")!.click()); + expect(localStorage.getItem("lightspeed:theme")).toBe("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); +}); diff --git a/platform/web/src/pages/AdminChannelsPage.test.tsx b/platform/web/src/pages/AdminChannelsPage.test.tsx new file mode 100644 index 00000000..44559131 --- /dev/null +++ b/platform/web/src/pages/AdminChannelsPage.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { ChannelConnectorHealth, ChannelsStatus } from "@/api"; +import { AdminChannelsPage } from "./AdminChannelsPage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); + +const health = (overrides: Partial): ChannelConnectorHealth => ({ + version: 1, provider: "telegram", accountId: "support", state: "ready", ingressConnected: true, + activityWorkerReady: true, reconnectAttempts: 0, changedAtMs: 0, ...overrides, +}); +const status: ChannelsStatus = { + connectors: [ + { + url: "http://telegram:8080", reachable: true, httpStatus: 200, + health: { + version: 1, state: "degraded", startedAtMs: 0, discovery: { passes: 1 }, + accounts: [ + health({ universeId: "core-factory", accountId: "support" }), + health({ universeId: "core-unknown", accountId: "sales", state: "failed", ingressConnected: false, lastError: "token revoked" }), + ], + }, + }, + { url: "http://whatsapp:8080", reachable: true, httpStatus: 200, health: health({ provider: "whatsapp", accountId: "ops", activityWorkerReady: false, detail: "waiting for pairing" }) }, + { url: "http://idle:8080", reachable: true, httpStatus: 200 }, + { url: "http://down:8080", reachable: false, httpStatus: null, error: "connection refused" }, + ], +}; +const accounts = { + accounts: [ + { universeId: "core-factory", accountId: "support", displayName: "Support line", provider: "telegram", providerAccountId: "@support_bot" }, + { universeId: "core-gone", accountId: "legacy", displayName: "Legacy", provider: "whatsapp", providerAccountId: "+100", enabled: false }, + ], +}; +let routes: Record unknown>; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + routes = { + "/api/v1/universes": () => [{ id: "factory", lightspeedUniverseId: "core-factory", name: "Factory", slug: "factory", status: "active", features: {} }], + "/api/v1/status/channels": () => status, + "/api/v1/channel-accounts": () => accounts, + }; + mocks.api.mockReset().mockImplementation(async (method: string, path: string) => { + const route = routes[path]; + if (method !== "GET" || !route) throw new Error(`Unexpected request: ${method} ${path}`); + return route(); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function show() { + await act(async () => root.render()); +} +const tables = () => [...container.querySelectorAll("table")]; +const cells = (table: HTMLTableElement | undefined) => + [...table?.querySelectorAll("tbody tr") ?? []].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim())); + +it("lists one health row per connector account, and the host itself when it reports none", async () => { + await show(); + await waitForUi(() => expect(tables()).toHaveLength(2)); + expect(cells(tables()[0])).toEqual([ + ["telegram / support", "Factory", "ready", "yes", "yes", "—"], + ["telegram / sales", "core-unknown", "failed", "no", "yes", "token revoked"], + ["whatsapp / ops", "—", "ready", "yes", "no", "waiting for pairing"], + ["http://idle:8080", "—", "no accounts", "—", "—", "—"], + ["http://down:8080", "—", "unreachable", "—", "—", "connection refused"], + ]); +}); + +it("lists the account inventory read-only, enabled unless stated otherwise", async () => { + await show(); + await waitForUi(() => expect(tables()).toHaveLength(2)); + expect(cells(tables()[1])).toEqual([ + ["Support linesupport", "telegram@support_bot", "Factory", "enabled"], + ["Legacylegacy", "whatsapp+100", "core-gone", "disabled"], + ]); + expect(container.querySelectorAll("tbody button")).toHaveLength(0); +}); + +it("says when no connector host is configured", async () => { + routes["/api/v1/status/channels"] = () => ({ connectors: [] }); + await show(); + await waitForUi(() => expect(container.textContent).toContain("No connector hosts are configured.")); + expect(tables()).toHaveLength(1); +}); + +it("keeps the inventory when connector status is unavailable", async () => { + routes["/api/v1/status/channels"] = () => { throw new Error("status endpoint down"); }; + await show(); + await waitForUi(() => expect(container.textContent).toContain("status endpoint down")); + expect(tables()).toHaveLength(1); + expect(cells(tables()[0])[0]?.[0]).toBe("Support linesupport"); +}); diff --git a/platform/web/src/pages/AdminEnvironmentProvidersPage.test.tsx b/platform/web/src/pages/AdminEnvironmentProvidersPage.test.tsx new file mode 100644 index 00000000..0ef9f00f --- /dev/null +++ b/platform/web/src/pages/AdminEnvironmentProvidersPage.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AdminEnvironmentProvidersPage } from "./AdminEnvironmentProvidersPage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); +// JSDOM has no popup layout; a native select stands in for the universe picker. +vi.mock("@/components/ui/select", async () => { + const React = await import("react"); + function SelectTrigger(_props: { children?: ReactNode }) { + return null; + } + function Select({ value, onValueChange, children }: { value: string; onValueChange: (value: string) => void; children: ReactNode }) { + const options: { value: string; label: ReactNode }[] = []; + const visit = (nodes: ReactNode) => React.Children.forEach(nodes, (child) => { + if (!React.isValidElement(child) || child.type === SelectTrigger) return; + const props = child.props as { value?: string; children?: ReactNode }; + if (props.value !== undefined) options.push({ value: props.value, label: props.children }); + else if (props.children) visit(props.children); + }); + visit(children); + return ; + } + const Part = () => null; + return { Select, SelectTrigger, SelectContent: ({ children }: { children?: ReactNode }) => <>{children}, SelectItem: Part, SelectValue: Part }; +}); + +type Binding = { providerId: string; enabled: boolean }; +const provider = (providerId: string, overrides: Record = {}) => ({ + providerId, controllerEndpoint: `wss://${providerId}.internal/control`, hasCredential: false, + effectiveDataRouteBase: `wss://${providerId}.internal/routes`, ...overrides, +}); +const universe = (name: string, bindings: Binding[], overrides: Record = {}) => ({ + universeId: `platform-${name}`, lightspeedUniverseId: `core-${name}`, name, status: "active", bindings, error: null, ...overrides, +}); +let providers: unknown[]; +let bindings: unknown[]; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("PointerEvent", MouseEvent); + providers = [ + provider("incus-b", { displayName: "Incus B", hasCredential: true, dataRouteBase: "wss://routes.internal", effectiveDataRouteBase: "wss://routes.internal" }), + provider("incus-a"), + ]; + bindings = [ + universe("Zeta", [{ providerId: "incus-a", enabled: true }]), + universe("Alpha", [{ providerId: "incus-a", enabled: false }]), + universe("Beta", []), + universe("Archived", [], { status: "archived" }), + universe("Broken", [], { error: "engine timeout" }), + ]; + mocks.api.mockReset().mockImplementation(async (method: string, path: string) => { + if (method === "GET" && path === "/api/v1/admin/environment-providers") return providers; + if (method === "GET" && path === "/api/v1/admin/environment-provider-bindings") return bindings; + if (method !== "GET") return {}; + throw new Error(`Unexpected request: ${method} ${path}`); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function show() { + await act(async () => root.render()); + await waitForUi(() => expect(container.querySelectorAll("section").length).toBe(providers.length)); +} +const sections = () => [...container.querySelectorAll("section")]; +const section = (providerId: string) => sections().find((node) => node.textContent?.includes(providerId))!; +const buttonIn = (scope: ParentNode, label: string) => [...scope.querySelectorAll("button")].find((node) => node.textContent?.trim() === label); +const dialog = () => document.querySelector('[role="dialog"]'); +const alertDialog = () => document.querySelector('[role="alertdialog"]'); +const writes = () => mocks.api.mock.calls.filter(([method]) => method !== "GET"); +async function type(selector: string, value: string) { + const node = document.querySelector(selector)!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const submit = () => act(async () => dialog()!.querySelector("form")!.requestSubmit()); + +it("lists providers by id with their routes, credential, and bound universes", async () => { + await show(); + expect(sections().map((node) => node.querySelector("h2")?.textContent)).toEqual(["incus-a", "Incus B"]); + expect(section("incus-a").textContent).toContain("no credential"); + expect(section("incus-a").textContent).toContain("Data routes wss://incus-a.internal/routes (derived)"); + expect(section("incus-b").textContent).toContain("credential"); + expect(section("incus-b").textContent).not.toContain("(derived)"); + expect(section("incus-b").textContent).toContain("No universe is bound to this provider yet."); + const bound = [...section("incus-a").querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent); + expect(bound).toEqual(["Alphacore-Alpha", "Zetacore-Zeta"]); + expect(container.textContent).toContain("Bindings could not be read for: Broken (engine timeout)"); +}); + +it("says when no provider is registered", async () => { + providers = []; + await act(async () => root.render()); + await waitForUi(() => expect(container.textContent).toContain("No environment providers are registered.")); +}); + +it.each([ + { name: "a missing id", fields: { "#provider-endpoint": "wss://x/control" }, error: "a provider id is required" }, + { name: "a missing endpoint", fields: { "#provider-id": "incus-c" }, error: "a controller endpoint is required" }, + { name: "an http endpoint", fields: { "#provider-id": "incus-c", "#provider-endpoint": "https://x/control" }, error: "must be a ws://, wss:// or unix:// URL" }, + { name: "an http data route", fields: { "#provider-id": "incus-c", "#provider-endpoint": "wss://x/control", "#provider-data-route-base": "http://x/routes" }, error: "the data route base must be a ws:// or wss:// URL" }, + { name: "a unix controller without data routes", fields: { "#provider-id": "incus-c", "#provider-endpoint": "unix:///run/incus.sock" }, error: "a unix:// controller needs a data route base" }, +])("refuses to register a provider with $name", async ({ fields, error }) => { + await show(); + await act(async () => buttonIn(container, "Register provider")!.click()); + await waitForUi(() => expect(dialog()?.textContent).toContain("Register environment provider")); + for (const [selector, value] of Object.entries(fields)) await type(selector, value); + await submit(); + expect(dialog()!.textContent).toContain(error); + expect(writes()).toHaveLength(0); +}); + +it("registers a provider with only the fields that were filled in", async () => { + await show(); + await act(async () => buttonIn(container, "Register provider")!.click()); + await waitForUi(() => expect(dialog()).not.toBeNull()); + await type("#provider-id", " incus-c "); + await type("#provider-endpoint", " wss://incus-c.internal:19090/control "); + expect(document.querySelector("#provider-data-route-base")!.placeholder).toBe("wss://incus-c.internal:19090/routes"); + await type("#provider-credential", "secret"); + await submit(); + await waitForUi(() => expect(dialog()).toBeNull()); + expect(mocks.api).toHaveBeenCalledWith("PUT", "/api/v1/admin/environment-providers/incus-c", { + controllerEndpoint: "wss://incus-c.internal:19090/control", + credential: "secret", + }); +}); + +it("edits a provider under its fixed id and can drop the stored credential", async () => { + await show(); + await act(async () => buttonIn(section("incus-b"), "Edit")!.click()); + await waitForUi(() => expect(dialog()?.textContent).toContain("Edit Incus B")); + expect(document.querySelector("#provider-id")!.disabled).toBe(true); + const credential = document.querySelector("#provider-credential")!; + expect(credential.placeholder).toBe("Unchanged"); + const clear = [...dialog()!.querySelectorAll('input[type="checkbox"]')].find((node) => node.closest("label")?.textContent?.includes("Remove the stored credential"))!; + await act(async () => clear.click()); + expect(credential.disabled).toBe(true); + await act(async () => buttonIn(dialog()!, "Save")!.click()); + await waitForUi(() => expect(dialog()).toBeNull()); + expect(mocks.api).toHaveBeenCalledWith("PUT", "/api/v1/admin/environment-providers/incus-b", { + displayName: "Incus B", + controllerEndpoint: "wss://incus-b.internal/control", + dataRouteBase: "wss://routes.internal", + clearCredential: true, + }); +}); + +it("toggles a binding and deletes one only after confirmation", async () => { + await show(); + const toggle = section("incus-a").querySelector('[aria-label="Enable incus-a for Alpha"]')!; + expect(toggle.getAttribute("aria-checked")).toBe("false"); + await act(async () => toggle.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("PUT", "/api/v1/admin/universes/core-Alpha/environment-provider-bindings/incus-a", { enabled: true })); + + const zeta = [...section("incus-a").querySelectorAll("tbody tr")].find((row) => row.textContent?.includes("Zeta"))!; + await act(async () => buttonIn(zeta, "Delete")!.click()); + await waitForUi(() => expect(alertDialog()?.textContent).toContain("Unbind Zeta?")); + expect(mocks.api.mock.calls.some(([method]) => method === "DELETE")).toBe(false); + await act(async () => buttonIn(alertDialog()!, "Delete binding")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("DELETE", "/api/v1/admin/universes/core-Zeta/environment-provider-bindings/incus-a")); +}); + +it("binds only active, readable universes that are not bound yet", async () => { + await show(); + await act(async () => buttonIn(section("incus-a"), "Bind universe")!.click()); + await waitForUi(() => expect(dialog()?.textContent).toContain("Bind a universe to incus-a")); + const picker = dialog()!.querySelector('select[aria-label="Universe"]')!; + expect([...picker.options].map((option) => option.textContent)).toEqual(["Select a universe", "Beta"]); + await submit(); + expect(dialog()!.textContent).toContain("choose a universe"); + await act(async () => { picker.value = "core-Beta"; picker.dispatchEvent(new Event("change", { bubbles: true })); }); + await submit(); + await waitForUi(() => expect(dialog()).toBeNull()); + expect(mocks.api).toHaveBeenCalledWith("PUT", "/api/v1/admin/universes/core-Beta/environment-provider-bindings/incus-a", { enabled: true }); +}); + +it("holds the bind action when every active universe is already bound", async () => { + bindings = [universe("Zeta", [{ providerId: "incus-a", enabled: true }])]; + await show(); + await act(async () => buttonIn(section("incus-a"), "Bind universe")!.click()); + await waitForUi(() => expect(dialog()?.textContent).toContain("Every active universe on this deployment is already bound")); + expect(buttonIn(dialog()!, "Bind")!.disabled).toBe(true); +}); + +it("deletes a provider after confirmation and shows why the engine refused", async () => { + mocks.api.mockImplementation(async (method: string, path: string) => { + if (method === "DELETE") throw new Error("provider still has bindings"); + return path.endsWith("/environment-providers") ? providers : bindings; + }); + await show(); + await act(async () => buttonIn(section("incus-a"), "Delete")!.click()); + await waitForUi(() => expect(alertDialog()?.textContent).toContain("Delete provider incus-a?")); + await act(async () => buttonIn(alertDialog()!, "Delete provider")!.click()); + await waitForUi(() => expect(container.textContent).toContain("provider still has bindings")); + expect(mocks.api).toHaveBeenCalledWith("DELETE", "/api/v1/admin/environment-providers/incus-a"); +}); diff --git a/platform/web/src/pages/AdminUniversesPage.test.tsx b/platform/web/src/pages/AdminUniversesPage.test.tsx new file mode 100644 index 00000000..aec5d5ec --- /dev/null +++ b/platform/web/src/pages/AdminUniversesPage.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { EngineUniverse, Universe, UniverseReconcile } from "@/api"; +import { AdminUniversesPage } from "./AdminUniversesPage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); + +const universe = (id: string, overrides: Partial = {}): Universe => ({ + id, lightspeedUniverseId: `core-${id}`, name: id, slug: id, gatewayUrl: null, status: "active", + createdAt: "2026-01-01T00:00:00Z", role: null, features: { bots: true, channels: true }, ...overrides, +}); +const orphan = (universeId: string, overrides: Partial = {}): EngineUniverse => ({ + universeId, sessions: 3, workspaces: 1, profiles: 2, blobBytes: 2048, createdAtMs: 0, ...overrides, +}); +let universes: Universe[]; +let reconcile: UniverseReconcile | Error; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("PointerEvent", MouseEvent); + universes = [ + universe("zeta", { name: "Zeta", role: "admin" }), + universe("alpha", { name: "Alpha", status: "archived" }), + universe("mid", { name: "Mid", features: { bots: false, channels: false } }), + ]; + reconcile = { + platform: [ + { id: "zeta", lightspeedUniverseId: "core-zeta", engine: "ok" }, + { id: "mid", lightspeedUniverseId: "core-mid", engine: "missing" }, + { id: "alpha", lightspeedUniverseId: "core-alpha", engine: "unchecked" }, + ], + orphans: [orphan("core-orphan", { slug: "kept" }), orphan("core-bare", { blobBytes: 5 * 1024 * 1024 })], + }; + mocks.api.mockReset().mockImplementation(async (method: string, path: string) => { + if (method === "GET" && path === "/api/v1/universes") return universes; + if (method === "GET" && path === "/api/v1/universes/reconcile") { + if (reconcile instanceof Error) throw reconcile; + return reconcile; + } + if (method === "POST" && path === "/api/v1/universes/sync-slugs") return { updated: 2, skipped: 1 }; + if (method !== "GET") return {}; + throw new Error(`Unexpected request: ${method} ${path}`); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.unstubAllGlobals(); +}); + +function Location() { + return

{useLocation().pathname}

; +} +async function show() { + await act(async () => root.render( + + + } /> + } /> + + , + )); + await waitForUi(() => expect(container.querySelectorAll("table").length).toBeGreaterThan(0)); +} +const tables = () => [...container.querySelectorAll("table")]; +const rows = (table = tables()[0]) => [...table?.querySelectorAll("tbody tr") ?? []] as HTMLTableRowElement[]; +const row = (name: string) => rows().find((node) => node.querySelector("td")?.textContent?.startsWith(name))!; +const cells = (node: HTMLTableRowElement) => [...node.querySelectorAll("td")].map((cell) => cell.textContent?.trim()); +const buttonIn = (scope: ParentNode, label: string) => [...scope.querySelectorAll("button")].find((node) => node.textContent?.trim() === label); +const alertDialog = () => document.querySelector('[role="alertdialog"]'); +const writes = () => mocks.api.mock.calls.filter(([method]) => method !== "GET"); + +it("lists every universe by name with its status, engine link, and role", async () => { + await show(); + expect(rows().map((node) => cells(node).slice(0, 4))).toEqual([ + ["Alphaalpha", "archived", "unchecked", "—"], + ["Midmid", "active", "missing", "—"], + ["Zetazeta", "active", "ok", "admin"], + ]); +}); + +it("offers recreate only where the engine tenant is missing, and restore and delete only once archived", async () => { + await show(); + expect(buttonIn(row("Mid"), "Recreate on engine")).toBeDefined(); + expect(buttonIn(row("Zeta"), "Recreate on engine")).toBeUndefined(); + expect(buttonIn(row("Zeta"), "Archive")).toBeDefined(); + expect(buttonIn(row("Zeta"), "Delete")).toBeUndefined(); + expect(buttonIn(row("Alpha"), "Archive")).toBeUndefined(); + expect(buttonIn(row("Alpha"), "Restore")).toBeDefined(); + expect(buttonIn(row("Alpha"), "Delete")).toBeDefined(); + + await act(async () => buttonIn(row("Mid"), "Recreate on engine")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/mid/engine", {})); + await act(async () => buttonIn(row("Zeta"), "Archive")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/zeta", { status: "archived" })); + await act(async () => buttonIn(row("Alpha"), "Restore")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/alpha", { status: "active" })); +}); + +it("purges an archived universe only after confirmation", async () => { + await show(); + await act(async () => buttonIn(row("Alpha"), "Delete")!.click()); + await waitForUi(() => expect(alertDialog()?.textContent).toContain("Permanently delete Alpha?")); + expect(writes()).toHaveLength(0); + await act(async () => buttonIn(alertDialog()!, "Delete permanently")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("DELETE", "/api/v1/universes/alpha")); +}); + +it("opens a universe at its landing page", async () => { + await show(); + await act(async () => buttonIn(row("Mid"), "Open")!.click()); + await waitForUi(() => expect(container.querySelector('[data-testid="location"]')?.textContent).toBe("/u/mid/sessions")); +}); + +it("reports what a runtime slug sync updated and skipped", async () => { + await show(); + await act(async () => buttonIn(container, "Sync from runtime")!.click()); + await waitForUi(() => expect(container.querySelector('[role="status"]')?.textContent).toContain("Synced runtime slugs: 2 updated.")); + expect(container.querySelector('[role="status"]')?.textContent).toContain("1 missing or unnamed runtime universes skipped"); + expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/sync-slugs", {}); +}); + +it("lists engine-only universes with their size, and deletes one after confirmation", async () => { + await show(); + expect(rows(tables()[1]).map(cells)).toEqual([ + ["core-orphan", "3", "1", "2", "2.0 KB", "AdoptDelete"], + ["core-bare", "3", "1", "2", "5.0 MB", "AdoptDelete"], + ]); + await act(async () => buttonIn(rows(tables()[1])[1]!, "Delete")!.click()); + await waitForUi(() => expect(alertDialog()?.textContent).toContain("Purges 3 session(s) and 5.0 MB of blobs")); + await act(async () => buttonIn(alertDialog()!, "Delete permanently")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("DELETE", "/api/v1/universes/engine/core-bare")); +}); + +it("adopts an orphan with a new slug, refusing an invalid one first", async () => { + await show(); + await act(async () => buttonIn(rows(tables()[1])[1]!, "Adopt")!.click()); + await waitForUi(() => expect(document.querySelector("#adopt-name")).not.toBeNull()); + const form = document.querySelector('[role="dialog"] form')!; + // The slug is a required input, so the name check is reached with one filled in. + await type("#adopt-slug", "-bad"); + await act(async () => form.requestSubmit()); + expect(form.textContent).toContain("a name is required"); + await type("#adopt-name", "Recovered"); + await act(async () => form.requestSubmit()); + expect(form.textContent).toContain("Enter a slug starting with a letter or digit"); + expect(writes()).toHaveLength(0); + await type("#adopt-slug", "recovered"); + await act(async () => form.requestSubmit()); + await waitForUi(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/adopt", { lightspeedUniverseId: "core-bare", name: "Recovered", slug: "recovered" }); +}); + +it("adopts an orphan under the runtime slug it already has", async () => { + await show(); + await act(async () => buttonIn(rows(tables()[1])[0]!, "Adopt")!.click()); + await waitForUi(() => expect(document.querySelector("#adopt-slug")?.readOnly).toBe(true)); + expect(document.querySelector("#adopt-slug")!.value).toBe("kept"); + await type("#adopt-name", "Kept"); + await act(async () => document.querySelector('[role="dialog"] form')!.requestSubmit()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/adopt", { lightspeedUniverseId: "core-orphan", name: "Kept" })); +}); + +it("keeps the inventory when engine reconciliation is unavailable", async () => { + reconcile = new Error("engine unreachable"); + await show(); + await waitForUi(() => expect(container.textContent).toContain("Engine reconciliation unavailable: engine unreachable")); + expect(rows()).toHaveLength(3); + expect(cells(row("Mid"))[2]).toBe("—"); + expect(tables()).toHaveLength(1); +}); + +async function type(selector: string, value: string) { + const node = document.querySelector(selector)!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + }); +} diff --git a/platform/web/src/pages/BotCreatePage.test.tsx b/platform/web/src/pages/BotCreatePage.test.tsx new file mode 100644 index 00000000..69b719ad --- /dev/null +++ b/platform/web/src/pages/BotCreatePage.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { PermissionIdentityProvider } from "@/lib/permissions"; +import { BotCreateDialog } from "./BotCreatePage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn(), role: "operator" })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); +vi.mock("@/lib/universes", async (original) => ({ + ...await original(), + useActiveUniverse: () => ({ universe: { id: "universe", role: mocks.role }, slug: "factory", isLoading: false }), + useFeature: () => true, +})); +vi.mock("@/lib/sessions/editor-options", () => ({ useSessionConfigEditorOptions: () => ({}) })); +vi.mock("@/components/session/session-config-editor", () => ({ SessionConfigEditor: () =>
})); +vi.mock("@/components/provider-readiness-banner", () => ({ ProviderReadinessBanner: () => null })); +// JSDOM has no popup layout; each select becomes a native control named by its trigger id. +vi.mock("@/components/ui/select", async () => { + const React = await import("react"); + function SelectTrigger(_props: { id?: string; children?: ReactNode }) { + return null; + } + function Select({ value, onValueChange, children }: { value: string; onValueChange: (value: string) => void; children: ReactNode }) { + const options: { value: string; label: ReactNode }[] = []; + let id: string | undefined; + const visit = (nodes: ReactNode) => React.Children.forEach(nodes, (child) => { + if (!React.isValidElement(child)) return; + const props = child.props as { value?: string; children?: ReactNode; id?: string }; + if (child.type === SelectTrigger) id = props.id; + else if (props.value !== undefined) options.push({ value: props.value, label: props.children }); + else if (props.children) visit(props.children); + }); + visit(children); + return ; + } + const Part = () => null; + return { Select, SelectTrigger, SelectContent: ({ children }: { children?: ReactNode }) => <>{children}, SelectItem: Part, SelectValue: Part }; +}); + +let bots: Array<{ botId: string }>; +let profiles: Array<{ profileId: string; displayName?: string; revision: number }>; +let failCreate: boolean; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("PointerEvent", MouseEvent); + mocks.role = "operator"; + bots = [{ botId: "taken" }]; + profiles = [{ profileId: "shared-support", displayName: "Shared support", revision: 1 }, { profileId: "claimed", revision: 1 }]; + failCreate = false; + mocks.api.mockReset().mockImplementation(async (method: string, path: string, body?: { bot?: { botId: string } }) => { + if (method === "GET" && path === "/api/v1/universes/universe/bots") return { bots }; + if (method === "GET" && path === "/api/v1/universes/universe/profiles") return profiles; + if (method === "GET" && path.startsWith("/api/v1/universes/universe/profiles/")) return { profileId: path.split("/").pop(), revision: 1 }; + if (method === "PUT" && path.startsWith("/api/v1/universes/universe/profiles/")) return {}; + if (method === "DELETE" && path.startsWith("/api/v1/universes/universe/profiles/")) return {}; + if (method === "POST" && path === "/api/v1/universes/universe/bots") { + if (failCreate) throw new Error("bot quota reached"); + return { bot: { botId: body!.bot!.botId } }; + } + throw new Error(`Unexpected request: ${method} ${path}`); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.unstubAllGlobals(); +}); + +function Location() { + const location = useLocation(); + return

{location.pathname}{location.state?.introduce ? " (introduce)" : ""}

; +} +async function show() { + await act(async () => root.render( + + + undefined} />} /> + } /> + + , + )); + if (mocks.role !== "viewer" && mocks.role !== "contributor") { + await waitForUi(() => expect(dialog()?.textContent).toContain("What is this bot's job?")); + } +} +const dialog = () => document.querySelector('[role="dialog"]'); +const input = (id: string) => document.querySelector(`#${id}`)!; +const buttons = () => [...dialog()!.querySelectorAll("button")]; +const template = (name: string) => buttons().find((node) => node.getAttribute("aria-pressed") !== null && node.textContent?.includes(name))!; +const step = (label: string) => [...dialog()!.querySelectorAll('[aria-label="Bot creation progress"] button')].find((node) => node.textContent?.endsWith(label))!; +const createButton = () => buttons().find((node) => node.textContent?.trim() === "Create bot"); +const location = () => container.querySelector('[data-testid="location"]')?.textContent; +async function click(node: HTMLElement | undefined) { + expect(node).toBeTruthy(); + await act(async () => node!.click()); +} +async function type(id: string, value: string) { + const node = input(id); + const prototype = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + await act(async () => { + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const calls = (method: string) => mocks.api.mock.calls.filter(([verb]) => verb === method); + +it.each(["viewer", "contributor"])("offers no bot creation to a %s", async (role) => { + mocks.role = role; + await show(); + expect(dialog()).toBeNull(); +}); + +it("fills a template's suggested name and replaces it with the next template's, but keeps a typed name", async () => { + await show(); + await click(template("Pull-request reviewer")); + expect(template("Pull-request reviewer").getAttribute("aria-pressed")).toBe("true"); + expect(template("Pull-request reviewer").textContent).toContain("GitHub events"); + expect([input("new-bot-name").value, input("new-bot-id").value]).toEqual(["Reviewer", "reviewer"]); + expect(input("new-bot-brief").value).toContain("You review pull requests"); + + await click(template("Daily digest")); + expect([input("new-bot-name").value, input("new-bot-id").value]).toEqual(["Digest", "digest"]); + await click(template("Blank")); + expect([input("new-bot-name").value, input("new-bot-id").value, input("new-bot-brief").value]).toEqual(["", "", ""]); + + await type("new-bot-name", "Morning Paper"); + expect(input("new-bot-id").value).toBe("morning-paper"); + await click(template("Daily digest")); + expect([input("new-bot-name").value, input("new-bot-id").value]).toEqual(["Morning Paper", "morning-paper"]); +}); + +it("keeps an id the person typed when the name changes", async () => { + await show(); + await type("new-bot-id", "ops"); + await type("new-bot-name", "Operations Desk"); + expect(input("new-bot-id").value).toBe("ops"); +}); + +it("creates the bot's own profile, then the bot with the template's triggers, and opens it", async () => { + await show(); + await click(template("Daily digest")); + await type("new-bot-description", " Writes the morning digest. "); + await click(step("Guardrails")); + expect(createButton()!.disabled).toBe(false); + await click(createButton()); + await waitForUi(() => expect(location()).toBe("/u/factory/bots/digest (introduce)")); + + expect(calls("PUT")).toEqual([[ + "PUT", "/api/v1/universes/universe/profiles/digest", + { profileId: "digest", displayName: "Digest", description: "Setup of bot digest", config: { features: { web: { fetch: {}, search: {} } } } }, + ]]); + const [, path, body] = calls("POST")[0]!; + expect(path).toBe("/api/v1/universes/universe/bots"); + expect(body.bot).toEqual({ + botId: "digest", displayName: "Digest", description: "Writes the morning digest.", profileId: "digest", + brief: expect.stringContaining("Every morning you prepare a digest"), runsPerDay: 10, selfConfig: true, emit: false, + }); + expect(body.triggers).toEqual([expect.objectContaining({ triggerId: "daily", kind: "schedule" })]); +}); + +it("removes the profile it just created when the bot cannot be created", async () => { + failCreate = true; + await show(); + await type("new-bot-name", "Triage"); + await click(step("Guardrails")); + await click(createButton()); + await waitForUi(() => expect(dialog()!.textContent).toContain("bot quota reached")); + expect(calls("DELETE")).toEqual([["DELETE", "/api/v1/universes/universe/profiles/triage"]]); + expect(location()).toBeUndefined(); +}); + +it("holds creation while the id is missing, taken by a bot, or taken by a profile", async () => { + await show(); + await click(step("Guardrails")); + expect(createButton()!.disabled).toBe(true); + expect(dialog()!.textContent).toContain("1 item needs attention"); + + await click(step("Job")); + await type("new-bot-name", "Taken"); + expect(input("new-bot-id").getAttribute("aria-invalid")).toBe("true"); + expect(dialog()!.textContent).toContain("Taken by another bot."); + await type("new-bot-name", "Claimed"); + await click(step("Guardrails")); + expect(createButton()!.disabled).toBe(true); + + // A profile with the bot's id is fine once the bot applies a shared profile instead. + await click(step("Session profile")); + await click(buttons().find((node) => node.textContent?.startsWith("A shared profile"))); + const picker = dialog()!.querySelector('[data-testid="select"]')!; + await act(async () => { picker.value = "shared-support"; picker.dispatchEvent(new Event("change", { bubbles: true })); }); + await click(step("Guardrails")); + expect(createButton()!.disabled).toBe(false); + await click(createButton()); + await waitForUi(() => expect(location()).toBe("/u/factory/bots/claimed (introduce)")); + expect(calls("PUT")).toHaveLength(0); + expect(calls("POST")[0]![2].bot.profileId).toBe("shared-support"); +}); + +it("requires a shared profile to be picked once that mode is chosen", async () => { + await show(); + await type("new-bot-name", "Helper"); + await click(step("Session profile")); + expect(dialog()!.querySelector('[data-testid="config-editor"]')).not.toBeNull(); + await click(buttons().find((node) => node.textContent?.startsWith("A shared profile"))); + expect(dialog()!.querySelector('[data-testid="config-editor"]')).toBeNull(); + await click(step("Guardrails")); + expect(createButton()!.disabled).toBe(true); +}); + +it("rejects a daily run limit below one", async () => { + await show(); + await type("new-bot-name", "Helper"); + await click(step("Guardrails")); + await type("new-bot-runs", "0"); + expect(createButton()!.disabled).toBe(true); + await type("new-bot-runs", ""); + expect(createButton()!.disabled).toBe(false); + await click(createButton()); + await waitForUi(() => expect(calls("POST")).toHaveLength(1)); + expect(calls("POST")[0]![2].bot).not.toHaveProperty("runsPerDay"); +}); + +it("opens the inbox when the bot may message other bots", async () => { + await show(); + await type("new-bot-name", "Relay"); + await click(step("Other bots")); + const inbox = () => dialog()!.querySelector("#new-bot-inbox")!; + expect(inbox().value).toBe("off"); + await click(dialog()!.querySelector("#new-bot-emit")!); + expect(inbox().value).toBe("any"); + await click(step("Guardrails")); + await click(createButton()); + await waitForUi(() => expect(calls("POST")).toHaveLength(1)); + const body = calls("POST")[0]![2]; + expect(body.bot.emit).toBe(true); + expect(body.triggers).toEqual([expect.objectContaining({ triggerId: "inbox", kind: "bot" })]); +}); + +it("adds and removes a trigger drafted from the picker", async () => { + await show(); + await type("new-bot-name", "Pinger"); + await click(step("Triggers")); + await click(buttons().find((node) => node.textContent?.startsWith("Schedule"))); + await click(buttons().find((node) => node.textContent?.startsWith("Schedule"))); + expect(dialog()!.querySelectorAll('[aria-label="Remove trigger"]')).toHaveLength(2); + await click(dialog()!.querySelectorAll('[aria-label="Remove trigger"]')[0]); + expect(dialog()!.querySelectorAll('[aria-label="Remove trigger"]')).toHaveLength(1); +}); diff --git a/platform/web/src/pages/GeneralSettingsPage.test.tsx b/platform/web/src/pages/GeneralSettingsPage.test.tsx new file mode 100644 index 00000000..6a666004 --- /dev/null +++ b/platform/web/src/pages/GeneralSettingsPage.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Universe } from "@/api"; +import { PermissionIdentityProvider } from "@/lib/permissions"; +import { GeneralSettingsPage } from "./GeneralSettingsPage"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); + +let universe: Universe; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("PointerEvent", MouseEvent); + universe = { + id: "platform-id", lightspeedUniverseId: "core-id", name: "Factory", slug: "factory", gatewayUrl: null, + status: "active", createdAt: "2026-01-01T00:00:00Z", role: "admin", features: { bots: true, channels: true }, + }; + mocks.api.mockReset().mockImplementation(async (method: string, path: string, body?: Record) => { + if (method === "GET" && path === "/api/v1/universes") return [universe]; + if (method === "PATCH" && path === "/api/v1/universes/platform-id") return { ...universe, ...body }; + if (method === "PUT" && path === "/api/v1/universes/platform-id/slug") return { ...universe, ...body }; + throw new Error(`Unexpected request: ${method} ${path}`); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.unstubAllGlobals(); +}); + +function Location() { + return

{useLocation().pathname}

; +} +async function show(path = "/u/factory/settings/general") { + await act(async () => root.render( + + + } /> + } /> + + , + )); + await waitForUi(() => expect(container.textContent).not.toContain("Loading")); +} +const location = () => container.querySelector('[data-testid="location"]')?.textContent; +const button = (label: string) => [...document.body.querySelectorAll("button")].find((node) => node.textContent?.trim() === label); +const input = (id: string) => container.querySelector(`#${id}`)!; +async function type(id: string, value: string) { + const node = input(id); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const writes = () => mocks.api.mock.calls.filter(([method]) => method !== "GET"); + +it("hides settings from a member who cannot manage access", async () => { + universe.role = "operator"; + await show(); + expect(container.querySelector("#universe-name")).toBeNull(); + expect(container.textContent).not.toContain("Danger zone"); +}); + +it("shows the identifiers the universe is reached by", async () => { + await show(); + const codes = [...container.querySelectorAll("code")].map((node) => node.textContent); + expect(codes).toEqual(["factory", "core-id"]); +}); + +it("renames with a trimmed display name once it changes", async () => { + await show(); + const save = () => input("universe-name").closest("form")!.querySelector('button[type="submit"]')!; + expect(save().disabled).toBe(true); + await type("universe-name", " "); + expect(save().disabled).toBe(true); + await type("universe-name", " Plant "); + expect(save().disabled).toBe(false); + await act(async () => input("universe-name").closest("form")!.requestSubmit()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/platform-id", { name: "Plant" })); +}); + +it("holds an invalid slug and follows a saved slug to its new URL", async () => { + await show(); + await type("universe-slug", "-bad slug"); + expect(button("Save slug")!.disabled).toBe(true); + await type("universe-slug", " plant "); + expect(button("Save slug")!.disabled).toBe(false); + await act(async () => input("universe-slug").closest("form")!.requestSubmit()); + await waitForUi(() => expect(location()).toBe("/u/plant/settings/general")); + expect(mocks.api).toHaveBeenCalledWith("PUT", "/api/v1/universes/platform-id/slug", { slug: "plant" }); +}); + +it("shows a refused slug change and stays put", async () => { + mocks.api.mockImplementation(async (method: string) => { + if (method === "PUT") throw new Error("slug already in use"); + return [universe]; + }); + await show(); + await type("universe-slug", "taken"); + await act(async () => input("universe-slug").closest("form")!.requestSubmit()); + await waitForUi(() => expect(container.querySelector('[role="alert"]')?.textContent).toBe("slug already in use")); + expect(location()).toBe("/u/factory/settings/general"); +}); + +it("archives only after confirmation, then leaves the universe", async () => { + await show(); + await act(async () => button("Archive universe")!.click()); + await waitForUi(() => expect(document.querySelector('[role="alertdialog"]')?.textContent).toContain("Archive Factory?")); + expect(writes()).toHaveLength(0); + await act(async () => button("Archive")!.click()); + await waitForUi(() => expect(location()).toBe("/")); + expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/platform-id", { status: "archived" }); +}); + +it("restores an archived universe without a confirmation", async () => { + universe.status = "archived"; + await show(); + expect(button("Archive universe")).toBeUndefined(); + await act(async () => button("Restore universe")!.click()); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/platform-id", { status: "active" })); + expect(location()).toBe("/u/factory/settings/general"); +}); diff --git a/platform/web/src/pages/HomeRedirect.test.tsx b/platform/web/src/pages/HomeRedirect.test.tsx new file mode 100644 index 00000000..a30fa777 --- /dev/null +++ b/platform/web/src/pages/HomeRedirect.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { HomeRedirect } from "./HomeRedirect"; +import { waitForUi } from "@/test/wait-for-ui"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); + +const universe = (slug: string, overrides: Record = {}) => ({ + id: `id-${slug}`, lightspeedUniverseId: `core-${slug}`, name: slug, slug, gatewayUrl: null, status: "active", + createdAt: "2026-01-01T00:00:00Z", role: "viewer", features: { bots: true, channels: true }, ...overrides, +}); + +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.clear(); + mocks.api.mockReset(); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +function Landed() { + return

{useLocation().pathname}

; +} +async function show(admin: boolean) { + await act(async () => root.render( + + + } /> + } /> + + , + )); +} +const landed = () => container.querySelector('[data-testid="landed"]')?.textContent; + +it("returns to the last-visited universe while it is still a membership", async () => { + mocks.api.mockResolvedValue([universe("alpha"), universe("beta")]); + localStorage.setItem("lightspeed:last-universe", "beta"); + await show(false); + await waitForUi(() => expect(landed()).toBe("/u/beta/bots")); +}); + +it("falls back to the first active membership when the last universe is gone or not joined", async () => { + mocks.api.mockResolvedValue([ + universe("archived", { status: "archived" }), + universe("browsed", { role: null }), + universe("member", { features: { bots: false, channels: false } }), + ]); + localStorage.setItem("lightspeed:last-universe", "browsed"); + await show(true); + // Bots switched off lands on sessions instead. + await waitForUi(() => expect(landed()).toBe("/u/member/sessions")); +}); + +it("offers an admin a first universe when there is none", async () => { + mocks.api.mockResolvedValue([universe("browsed", { role: null })]); + await show(true); + await waitForUi(() => expect(container.textContent).toContain("No universes yet")); + expect(container.textContent).toContain("Create your first universe"); + expect(landed()).toBeUndefined(); + const create = [...container.querySelectorAll("button")].find((node) => node.textContent?.includes("New universe"))!; + await act(async () => create.click()); + await waitForUi(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain("New universe")); +}); + +it("tells a member who to ask, without a create action", async () => { + mocks.api.mockResolvedValue([]); + await show(false); + await waitForUi(() => expect(container.textContent).toContain("Ask a platform or universe admin")); + expect([...container.querySelectorAll("button")].some((node) => node.textContent?.includes("New universe"))).toBe(false); +}); + +it("shows why the universe list could not load", async () => { + mocks.api.mockRejectedValue(new Error("universes unavailable")); + await show(false); + await waitForUi(() => expect(container.textContent).toContain("universes unavailable")); + expect(landed()).toBeUndefined(); +}); From bf6c24c511408313cefdcc35e51ec7d7f77f8b3c Mon Sep 17 00:00:00 2001 From: Dan Krusi Date: Thu, 5 Nov 2026 12:17:08 +0100 Subject: [PATCH 2/2] Add Playwright visual diffs of the demo build Add two review commands that capture the demo build in a real browser and compare versions with screendiff-js: npm run screenshots:demo capture the working tree npm run screendiff:demo compare two versions (default: the working tree against where the branch left main) screendiff:demo takes optional [base] [head] refs plus --theme , --no-cache, and --keep-screenshots. The default base is the merge-base with main, not main's tip, so later changes on main stay out of the report. Capture (platform/web/e2e/, playwright.config.ts): - 27 demo routes in light and dark at 1440x900, 2x device scale. - Pins clock, randomness, animations, locale, and timezone from outside the app, so it works on any commit; repeated captures of one build match pixel for pixel. - The harness always comes from the working tree, so both sides capture the same screens. Specs use *.pw.ts so Vitest skips them. Runner (platform/web/scripts/screendiff.mjs): - Builds committed refs in a git worktree in the OS temp folder (outside the repo, so module resolution cannot fall back to this checkout's node_modules) and caches builds by SHA in node_modules/.cache. - Writes a self-contained report.html and results.json per comparison to platform/web/test-results/screendiff/_vs_/; captured PNGs are removed afterwards unless --keep-screenshots is given or the run failed. Visual tests are a review tool for UI/UX changes, deliberately not part of npm test, npm run check, or CI. platform/README.md gains a "Testing & Visual Tests" section. Dependencies: @playwright/test 1.64.1, screendiff-js 0.1.2, and @types/node as platform/web devDependencies. screendiff-js's peer dependency moves Vitest from 5.0.3 to 5.1.0 in the lockfile; the web suite passes. --- .gitignore | 1 + package-lock.json | 176 ++++++++++++----- package.json | 4 +- platform/README.md | 36 ++++ platform/web/e2e/screens.ts | 47 +++++ platform/web/e2e/tsconfig.json | 16 ++ platform/web/e2e/visual.pw.ts | 72 +++++++ platform/web/package.json | 9 +- platform/web/playwright.config.ts | 29 +++ platform/web/scripts/screendiff.mjs | 295 ++++++++++++++++++++++++++++ 10 files changed, 635 insertions(+), 50 deletions(-) create mode 100644 platform/web/e2e/screens.ts create mode 100644 platform/web/e2e/tsconfig.json create mode 100644 platform/web/e2e/visual.pw.ts create mode 100644 platform/web/playwright.config.ts create mode 100644 platform/web/scripts/screendiff.mjs diff --git a/.gitignore b/.gitignore index 38dc0ad2..2a0951a6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules/ .aos/ notes-world/ .playwright-mcp/ +platform/web/test-results/ .tmp/ refs/ .bridge-state.json diff --git a/package-lock.json b/package-lock.json index 40a5fb14..ad30af69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4585,6 +4585,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -6274,9 +6290,9 @@ } }, "node_modules/@types/node": { - "version": "24.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", - "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -6430,22 +6446,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.3", + "@vitest/spy": "5.1.0", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -6458,9 +6474,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", "devOptional": true, "license": "MIT", "funding": { @@ -12293,9 +12309,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -14601,6 +14617,19 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pixelmatch": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-8.0.0.tgz", + "integrity": "sha512-cIqOLfh5k52us715qf3B3+kTOxZv+WDv77HN7xokLScYQGYQHi4m/ynoDIPpvF2LvCyUJGyvzY64i0QCXtzu1g==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -14623,6 +14652,45 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -15812,6 +15880,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/screendiff-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/screendiff-js/-/screendiff-js-0.1.2.tgz", + "integrity": "sha512-m25RftgtsSpMMsYQXLYjYIKue9ByzIgihcK0CC94lxn3JwAGmLdj75sAAhmLLegaS2foWsLdpIRb0xNq8yNWCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pixelmatch": "8.0.0", + "sharp": "0.35.5" + }, + "bin": { + "screendiff": "dist/cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "vitest": "^5.1.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -16709,9 +16802,9 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", "devOptional": true, "license": "MIT", "engines": { @@ -17909,23 +18002,23 @@ } }, "node_modules/vitest": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.3", - "chai": "^6.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, @@ -17942,12 +18035,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.3", - "@vitest/browser-preview": "5.0.3", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.3", - "@vitest/coverage-v8": "5.0.3", - "@vitest/ui": "5.0.3", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -17991,20 +18084,6 @@ } } }, - "node_modules/vitest/node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "devOptional": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/volar-service-css": { "version": "0.0.71", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz", @@ -18959,10 +19038,13 @@ "zod": "^4.6.7" }, "devDependencies": { + "@playwright/test": "^1.64.1", + "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "jsdom": "^30.1.1", + "screendiff-js": "^0.1.2", "vite": "^8.3.5", "vitest": "^5.0.3" }, diff --git a/package.json b/package.json index 830bf05f..dd27f09f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "test:release-packaging": "npm --prefix clients/typescript ci --ignore-scripts && npm --prefix platform/configurator-mcp ci --ignore-scripts && scripts/release/stage-runtimes.sh", "typecheck": "tsc --noEmit && npm run typecheck --workspace @lightspeed-ai/connectors && npm run typecheck --workspace @lightspeed-ai/platform-web && npm run typecheck --workspace @lightspeed-ai/sdk && npm run typecheck --workspace @lightspeed-ai/configurator-mcp", "build:demo": "npm run build:demo --workspace @lightspeed-ai/platform-web", - "demo": "npm run dev:demo --workspace @lightspeed-ai/platform-web" + "demo": "npm run dev:demo --workspace @lightspeed-ai/platform-web", + "screenshots:demo": "npm run screenshots:demo --workspace @lightspeed-ai/platform-web --", + "screendiff:demo": "npm run screendiff:demo --workspace @lightspeed-ai/platform-web --" }, "devDependencies": { "@types/node": "^24.19.1", diff --git a/platform/README.md b/platform/README.md index d57aba5f..5bf18a71 100644 --- a/platform/README.md +++ b/platform/README.md @@ -299,3 +299,39 @@ and manifest keys stay stable). The connector host serves the providers named by `LIGHTSPEED_CONNECTOR_PROVIDERS` for every account the core reports; see `connectors/README.md`. The release manifest records one digest for each image. + +### Testing & Visual Tests + +The web UI's tests run in Vitest and are part of `npm test` and `npm run check`. +Logic tests call page helpers with plain data; component tests mount pages in +jsdom with the API mocked, click through them, and assert the resulting DOM and +API calls. They sit next to the code they cover (`*.test.ts`, `*.test.tsx`): + +```bash +npm run test --workspace @lightspeed-ai/platform-web +npm run test --workspace @lightspeed-ai/platform-web -- src/pages/AccountPage.test.tsx +``` + +Visual tests capture the demo build in a real browser with Playwright +(`web/e2e/`) and compare two versions with +[screendiff](https://github.com/dankrusi/screendiff). Each screen is captured +in light and dark at a 1440×900 viewport and 2× pixel density: + +```bash +npx playwright install chromium # once +npm run screenshots:demo # capture the working tree +npm run screendiff:demo # working tree vs. where the branch left main +npm run screendiff:demo -- main~3 HEAD # any two refs +npm run screendiff:demo -- --theme dark # one theme +``` + +Results go to `platform/web/test-results/`: screenshots, and one +self-contained `report.html` per comparison. The report embeds every image, so +a comparison removes its captured PNGs afterwards; pass `--keep-screenshots` to +keep them. A committed ref is built in a temporary worktree and its build is +cached by commit. + +Visual tests are not part of the automatic test process and should not be added +to `npm test`, `npm run check`, or CI. Use them as a review tool: to see what a +change does to the UI and UX, and to show it to others before merging. A +changed screen is a prompt to look, not a failure. diff --git a/platform/web/e2e/screens.ts b/platform/web/e2e/screens.ts new file mode 100644 index 00000000..12796285 --- /dev/null +++ b/platform/web/e2e/screens.ts @@ -0,0 +1,47 @@ +/// Screens captured from the demo build. Paths are relative to the demo base +/// (`/demo/`). Ids name the PNGs, so keep them stable: renaming one shows up +/// as a removed and an added screen in the next comparison. +/// +/// Only plain routes for now; screens that need a click first (open dialogs, +/// expanded panels) come later as per-screen setup steps. +export interface Screen { + id: string; + path: string; +} + +const factory = "u/software-factory"; + +export const SCREENS: Screen[] = [ + { id: "sessions", path: `${factory}/sessions` }, + { id: "session-detail", path: `${factory}/sessions/session-flaky-scheduler` }, + { id: "session-detail-audit", path: `${factory}/sessions/session-scheduler-audit` }, + { id: "bots", path: `${factory}/bots` }, + { id: "bot-chat", path: `${factory}/bots/planner` }, + { id: "bot-activity", path: `${factory}/bots/planner/activity` }, + { id: "bots-support", path: "u/technical-support/bots" }, + { id: "workspace", path: `${factory}/workspaces/acme-web` }, + { id: "workspace-file", path: `${factory}/workspaces/acme-web/files/docs/adr-0007.md` }, + { id: "profiles", path: `${factory}/profiles` }, + { id: "profile-detail", path: `${factory}/profiles/planner` }, + { id: "models", path: `${factory}/models` }, + { id: "environments", path: `${factory}/environments` }, + { id: "mcp-servers", path: `${factory}/mcp-servers` }, + { id: "credentials", path: `${factory}/credentials` }, + { id: "api-keys", path: `${factory}/api-keys` }, + { id: "members", path: `${factory}/members` }, + { id: "settings-general", path: `${factory}/settings/general` }, + { id: "settings-channels", path: `${factory}/settings/channels` }, + { id: "settings-templates", path: `${factory}/settings/templates` }, + { id: "admin-users", path: "admin/users" }, + { id: "admin-audit", path: "admin/audit" }, + { id: "admin-universes", path: "admin/universes" }, + { id: "admin-api-keys", path: "admin/api-keys" }, + { id: "admin-channels", path: "admin/channels" }, + { id: "admin-environment-providers", path: "admin/environment-providers" }, + { id: "account", path: "account" }, +]; + +export const THEMES = ["light", "dark"] as const; +export type Theme = (typeof THEMES)[number]; + +export const VIEWPORT = { name: "desktop", width: 1440, height: 900, scale: 2 } as const; diff --git a/platform/web/e2e/tsconfig.json b/platform/web/e2e/tsconfig.json new file mode 100644 index 00000000..99f6bea1 --- /dev/null +++ b/platform/web/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": [".", "../playwright.config.ts"] +} diff --git a/platform/web/e2e/visual.pw.ts b/platform/web/e2e/visual.pw.ts new file mode 100644 index 00000000..cd176076 --- /dev/null +++ b/platform/web/e2e/visual.pw.ts @@ -0,0 +1,72 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { SCREENS, THEMES, VIEWPORT, type Theme } from "./screens"; + +/// One test per screen and theme. Each writes `@-.png` +/// into SCREENSHOTS_OUT. This harness comes from the current tree and drives +/// both builds of a comparison, so both sides capture the same screens. +const out = process.env.SCREENSHOTS_OUT; +if (!process.env.SCREENSHOTS_BASE_URL || !out) { + throw new Error("Run through `npm run screenshots:demo` or `npm run screendiff:demo`."); +} +const themes = (process.env.SCREENSHOTS_THEMES?.split(",") ?? THEMES) as Theme[]; + +/// Everything the page could vary on is pinned from outside the app, so it +/// works for any commit, including ones that predate this harness. +const FIXED_TIME = new Date("2026-09-01T09:00:00.000Z"); +const STILL_CSS = `*, *::before, *::after { + animation-duration: 0s !important; animation-delay: 0s !important; + transition-duration: 0s !important; transition-delay: 0s !important; + caret-color: transparent !important; +}`; + +mkdirSync(out, { recursive: true }); + +for (const theme of themes) { + for (const screen of SCREENS) { + test(`${screen.id} (${theme})`, async ({ page }) => { + await pin(page, theme); + const response = await page.goto(screen.path); + expect(response?.ok(), `GET ${screen.path}`).toBe(true); + await page.addStyleTag({ content: STILL_CSS }); + await settle(page); + await page.screenshot({ path: path.join(out, `${screen.id}@${VIEWPORT.name}-${theme}.png`), fullPage: true, animations: "disabled", caret: "hide" }); + }); + } +} + +async function pin(page: Page, theme: Theme) { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.clock.setFixedTime(FIXED_TIME); + await page.addInitScript((stored) => { + localStorage.setItem("lightspeed:theme", stored); + // Seeded PRNG and counter UUIDs: ids and jitter repeat run to run. + let seed = 0x2f6b9a1d; + Math.random = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + let uuid = 0; + crypto.randomUUID = () => `00000000-0000-4000-8000-${(++uuid).toString(16).padStart(12, "0")}` as `${string}-${string}-${string}-${string}-${string}`; + }, theme); +} + +/// Ready when fonts are in, nothing reports loading, and two consecutive +/// frames match. The demo backend runs in the page, so there is no network +/// idle to wait for. +async function settle(page: Page) { + await page.evaluate(() => document.fonts.ready); + await expect(page.getByText(/^Loading…?$/)).toHaveCount(0); + await expect(page.locator('[aria-busy="true"]')).toHaveCount(0); + let previous = await page.screenshot({ fullPage: true, animations: "disabled", caret: "hide" }); + for (let attempt = 0; attempt < 10; attempt++) { + await page.waitForTimeout(150); + const next = await page.screenshot({ fullPage: true, animations: "disabled", caret: "hide" }); + if (next.equals(previous)) return; + previous = next; + } + throw new Error("The page kept changing; mask or pin whatever moves."); +} diff --git a/platform/web/package.json b/platform/web/package.json index 011e1035..3365d4fa 100644 --- a/platform/web/package.json +++ b/platform/web/package.json @@ -8,14 +8,16 @@ "build": "vite build", "build:demo": "vite build --mode demo", "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit" + "screenshots:demo": "node scripts/screendiff.mjs capture", + "screendiff:demo": "node scripts/screendiff.mjs diff", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p e2e/tsconfig.json" }, "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", - "@lightspeed-ai/sdk": "*", "@lightspeed-ai/platform-shared": "*", + "@lightspeed-ai/sdk": "*", "@shadcn/react": "^0.3.1", "@tabler/icons-react": "^3.48.0", "@tailwindcss/vite": "^4.3.3", @@ -41,10 +43,13 @@ "zod": "^4.6.7" }, "devDependencies": { + "@playwright/test": "^1.64.1", + "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "jsdom": "^30.1.1", + "screendiff-js": "^0.1.2", "vite": "^8.3.5", "vitest": "^5.0.3" }, diff --git a/platform/web/playwright.config.ts b/platform/web/playwright.config.ts new file mode 100644 index 00000000..feb95def --- /dev/null +++ b/platform/web/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "@playwright/test"; +import { VIEWPORT } from "./e2e/screens"; + +/// Browser tests against the built demo. `scripts/screendiff.mjs` serves the +/// build and sets the environment. Specs are `*.pw.ts` so Vitest's default +/// `*.test`/`*.spec` glob skips them. +export default defineConfig({ + testDir: "e2e", + testMatch: "*.pw.ts", + // Playwright empties this before each run; it must not be test-results/, + // where one side's screenshots wait for the other side's capture. + outputDir: "node_modules/.cache/playwright", + fullyParallel: true, + forbidOnly: true, + retries: 0, + timeout: 60_000, + reporter: [["list"]], + use: { + baseURL: process.env.SCREENSHOTS_BASE_URL, + browserName: "chromium", + viewport: { width: VIEWPORT.width, height: VIEWPORT.height }, + // High-DPI captures (2880×1800 for the desktop viewport), so small text + // and one-pixel borders diff at the resolution people review on. + deviceScaleFactor: VIEWPORT.scale, + locale: "en-US", + timezoneId: "UTC", + reducedMotion: "reduce", + }, +}); diff --git a/platform/web/scripts/screendiff.mjs b/platform/web/scripts/screendiff.mjs new file mode 100644 index 00000000..09a759c7 --- /dev/null +++ b/platform/web/scripts/screendiff.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +/// Visual diffs of the demo build. +/// +/// screenshots:demo [--theme [,...]] +/// Builds the working tree's demo and captures it into +/// test-results/screenshots/. +/// +/// screendiff:demo [base] [head] [--theme [,...]] [--no-cache] [--keep-screenshots] +/// Captures two versions and compares them with screendiff-js into +/// test-results/screendiff/_vs_/, replacing the previous run +/// of the same comparison. The self-contained report.html and +/// results.json remain; the captured PNGs are removed unless +/// --keep-screenshots is given or the run failed. `base` defaults to where the branch split off +/// main (`git merge-base HEAD main`), not main's tip, so changes merged to +/// main since then stay out of the report. `head` defaults to the working +/// tree, uncommitted changes included. +/// +/// Committed refs are built in a git worktree in the OS temp folder and the +/// build is cached by commit SHA in node_modules/.cache/. The worktree stays +/// outside the repository on purpose: inside it, module resolution would walk +/// up into this checkout's node_modules and could quietly build an old commit +/// against current packages. +/// +/// The capture harness and screen list always come from the working tree, so +/// both sides capture the same screens. +import { execFileSync, spawn } from "node:child_process"; +import { cpSync, createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const webDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = git(["rev-parse", "--show-toplevel"], webDir); +const resultsDir = path.join(webDir, "test-results"); +const buildCache = path.join(webDir, "node_modules", ".cache", "lightspeed-visual"); +const worktreeRoot = path.join(tmpdir(), "lightspeed-visual"); +/// Fixed port from the dev-port tool for this repository. +const PORT = 40938; +const BASE_PATH = "/demo/"; +const THEMES = ["light", "dark"]; +const requireFromWeb = createRequire(path.join(webDir, "package.json")); +/// Set by npm while a script runs, so a worktree uses the same npm. +const npmCli = process.env.npm_execpath; + +const [command, ...rest] = process.argv.slice(2); +let options; + +try { + options = parseOptions(rest); + if (command === "capture") process.exitCode = await captureCommand(); + else if (command === "diff") process.exitCode = await diffCommand(); + else fail(`usage: screendiff.mjs capture|diff [base] [head] [--theme light,dark] [--no-cache] [--keep-screenshots]`); +} catch (error) { + console.error(`\n${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 2; +} + +async function captureCommand() { + if (options.refs.length > 0) fail("screenshots:demo captures the working tree; use screendiff:demo to compare refs."); + const dist = buildWorkingTree(); + const out = path.join(resultsDir, "screenshots"); + const failed = await capture(dist, out); + console.log(`\nScreenshots: ${out}`); + return failed ? 1 : 0; +} + +async function diffCommand() { + if (options.refs.length > 2) fail("screendiff:demo takes at most two refs: [base] [head]."); + const [baseRef = mergeBase(), headRef] = options.refs; + const base = { label: shortRef(baseRef), dist: buildRef(baseRef) }; + const head = headRef ? { label: shortRef(headRef), dist: buildRef(headRef) } : { label: "working-tree", dist: buildWorkingTree() }; + + // One folder per comparison: running the same comparison again replaces it. + const run = path.join(resultsDir, "screendiff", `${safe(base.label)}_vs_${safe(head.label)}`); + rmSync(run, { recursive: true, force: true }); + const failedBase = await capture(base.dist, path.join(run, "base")); + const failedHead = await capture(head.dist, path.join(run, "head")); + + console.log(`\nComparing ${base.label} → ${head.label}`); + const report = path.join(run, "report.html"); + const code = await exec([ + ...bin("screendiff-js", "screendiff"), + "compare", path.join(run, "base"), path.join(run, "head"), + "--output", path.join(run, "results.json"), + "--report", report, + "--title", `Lightspeed demo: ${base.label} → ${head.label}`, + ], { cwd: webDir, allowFailure: true }); + const failed = code === 2 || failedBase || failedHead; + // The report embeds both images of every screen, so the PNGs are copies + // once it exists. A failed run keeps them for a look at what went wrong. + if (!failed && !options.keepScreenshots && existsSync(report)) { + rmSync(path.join(run, "base"), { recursive: true, force: true }); + rmSync(path.join(run, "head"), { recursive: true, force: true }); + } + console.log(`\nReport: ${report}`); + if (failedBase || failedHead) { + console.log("Some screens could not be captured (see above); they show as added or removed."); + } + return failed ? 2 : code; +} + +/// The working tree is rebuilt every time, into the cache so `dist-demo/` +/// is left alone. +function buildWorkingTree() { + const dist = path.join(buildCache, "working-tree"); + console.log("Building the working tree's demo…"); + ensureSdkBuilt(repoRoot); + run([...bin("vite"), "build", "--mode", "demo", "--outDir", dist, "--emptyOutDir", "--logLevel", "warn"], { cwd: webDir }); + return dist; +} + +/// A commit never changes, so its build is cached by SHA. +function buildRef(ref) { + const sha = git(["rev-parse", "--verify", `${ref}^{commit}`], repoRoot); + const dist = path.join(buildCache, sha); + if (options.cache && existsSync(path.join(dist, "index.html"))) { + console.log(`Using cached demo build of ${describe(ref, sha)}.`); + return dist; + } + const worktree = path.join(worktreeRoot, sha); + console.log(`Building the demo of ${describe(ref, sha)} in a temporary worktree: ${worktree}`); + removeStaleWorktrees(); + removeWorktree(worktree); + git(["worktree", "add", "--detach", worktree, sha], repoRoot); + try { + run(npm(["ci", "--no-audit", "--no-fund", "--loglevel", "error"]), { cwd: worktree }); + ensureSdkBuilt(worktree); + run(npm(["run", "build:demo", "--workspace", "@lightspeed-ai/platform-web"]), { cwd: worktree }); + rmSync(dist, { recursive: true, force: true }); + cpSync(path.join(worktree, "platform", "web", "dist-demo"), dist, { recursive: true }); + } finally { + removeWorktree(worktree); + } + return dist; +} + +function ensureSdkBuilt(root) { + if (existsSync(path.join(root, "clients", "typescript", "dist", "index.js"))) return; + run(npm(["run", "build", "--workspace", "@lightspeed-ai/sdk"]), { cwd: root }); +} + +/// Worktrees of this repository left in the temp folder by an interrupted run. +function removeStaleWorktrees() { + const listed = git(["worktree", "list", "--porcelain"], repoRoot) + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => path.resolve(line.slice("worktree ".length))); + for (const worktree of listed) { + if (path.dirname(worktree) === path.resolve(worktreeRoot)) removeWorktree(worktree); + } +} + +function removeWorktree(worktree) { + if (existsSync(worktree)) { + try { + git(["worktree", "remove", "--force", worktree], repoRoot); + } catch { + rmSync(worktree, { recursive: true, force: true }); + } + } + git(["worktree", "prune"], repoRoot); +} + +/// Serves one build and runs the Playwright harness against it. Resolves +/// whether any screen failed to capture. +async function capture(dist, out) { + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + const server = await serve(dist); + try { + console.log(`Capturing ${path.relative(repoRoot, out)}…`); + const code = await exec([...bin("@playwright/test", "playwright"), "test", "-c", "playwright.config.ts"], { + cwd: webDir, + allowFailure: true, + env: { + SCREENSHOTS_BASE_URL: `http://127.0.0.1:${PORT}${BASE_PATH}`, + SCREENSHOTS_OUT: out, + SCREENSHOTS_THEMES: options.themes.join(","), + }, + }); + return code !== 0; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +/// Static server for a built demo, with the SPA fallback to index.html. +function serve(dist) { + const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json", ".woff2": "font/woff2", ".woff": "font/woff", ".webmanifest": "application/manifest+json" }; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (!url.pathname.startsWith(BASE_PATH)) { + response.writeHead(302, { location: BASE_PATH }).end(); + return; + } + let file = path.join(dist, decodeURIComponent(url.pathname.slice(BASE_PATH.length))); + if (!file.startsWith(dist) || !existsSync(file) || statSync(file).isDirectory()) file = path.join(dist, "index.html"); + response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" }); + createReadStream(file).pipe(response); + }); + return new Promise((resolve, reject) => { + server.once("error", (error) => reject(error.code === "EADDRINUSE" ? new Error(`Port ${PORT} is in use; stop whatever serves it and retry.`) : error)); + server.listen(PORT, "127.0.0.1", () => resolve(server)); + }); +} + +function mergeBase() { + for (const main of ["main", "origin/main"]) { + try { + return git(["merge-base", "HEAD", main], repoRoot); + } catch { + // Try the next candidate. + } + } + fail("Could not find main or origin/main to compare against; pass a base ref."); +} + +function shortRef(ref) { + return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 10) : ref; +} + +function parseOptions(args) { + const parsed = { refs: [], themes: THEMES, cache: true, keepScreenshots: false }; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--theme" || arg.startsWith("--theme=")) { + const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++index]; + const themes = (value ?? "").split(",").filter(Boolean); + if (themes.length === 0 || themes.some((theme) => !THEMES.includes(theme))) fail(`--theme takes ${THEMES.join(", ")}, comma-separated.`); + parsed.themes = themes; + } else if (arg === "--no-cache") { + parsed.cache = false; + } else if (arg === "--keep-screenshots") { + parsed.keepScreenshots = true; + } else if (arg.startsWith("-")) { + fail(`Unknown option ${arg}.`); + } else { + parsed.refs.push(arg); + } + } + return parsed; +} + +/// A package's CLI, run with this Node. Resolving it the way Node does finds +/// it wherever npm hoisted it, and needs no shell. +function bin(pkg, name = pkg) { + // Not `resolve("/package.json")`: packages with an exports map may not export it. + const manifestPath = (requireFromWeb.resolve.paths(pkg) ?? []) + .map((dir) => path.join(dir, pkg, "package.json")) + .find((candidate) => existsSync(candidate)); + if (!manifestPath) fail(`${pkg} is not installed; run npm install.`); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin[name]; + return [process.execPath, path.join(path.dirname(manifestPath), entry)]; +} + +function npm(args) { + return npmCli ? [process.execPath, npmCli, ...args] : ["npm", ...args]; +} + +function git(args, cwd) { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +/// Commands are `[executable, ...args]`. Only a bare `npm` (no npm_execpath) +/// needs a shell on Windows, where it is a .cmd file. +function run([command, ...args], { cwd }) { + execFileSync(command, args, { cwd, stdio: "inherit", shell: command === "npm" && process.platform === "win32" }); +} + +function exec([command, ...args], { cwd, env = {}, allowFailure = false }) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: "inherit", env: { ...process.env, ...env } }); + child.once("error", reject); + child.once("exit", (code) => { + if (code !== 0 && !allowFailure) reject(new Error(`${path.basename(args[0] ?? command)} exited with ${code}`)); + else resolve(code ?? 1); + }); + }); +} + +function safe(label) { + return label.replace(/[^A-Za-z0-9._-]+/g, "-"); +} + +function fail(message) { + throw new Error(message); +} + +/// `main (d502672d1d)`, or just the short SHA when the ref already is one. +function describe(ref, sha) { + return sha.startsWith(ref) ? sha.slice(0, 10) : `${ref} (${sha.slice(0, 10)})`; +}