From f3ecd7de6372d68f4b113c0de556d41c4b1fabb7 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 18:45:48 +0800 Subject: [PATCH 1/5] test(webui): clear 85 of the type errors the test files were hiding The 67 test files outside `tsconfig.test.json`'s include had never been typechecked, which is how a PR shipped four call sites still calling a function whose third parameter had become required. Every error was traced to its reading site first: all 93 were fixtures or stubs of the wrong shape, none was a real defect, and no assertion's expected value had to change. Most of them mattered anyway, because a loose fixture hides more than a type: - `session-activity`'s project fixture omitted all eight required `WebuiProjectRecord` fields, so the rail could have read `pinned`, `hidden` or `recentAtMs` wrongly and still passed. It now carries a complete record. - `shell`'s assertion on `pendingUser` was reading a field that exists nowhere in `src/`; the test's own comment said the user line now comes from the replayed frame. It could never fail, so it is deleted rather than cast away. - `plugin-market-catalogue`'s `onClose` appears once and is never asserted; `PluginManagement` does not destructure it. Also deleted. - `transcript-retry-resend-wiring`'s submit fixture omitted `draft` while production passes `draft: turn.message`; if the resend path ever starts reading it, that fixture would have started lying. - `session-import`'s `fetchImpl` was cast to `typeof fetch`, which erased the `Mock` handle the assertions then reached through. `npx tsc` over the same full-coverage shape reports 35 errors, down from 123. Of the 19 test files touched, 5 errors remain and are deliberately left: three in `webui-w2-effect-reducer` where adding the required `generation` also lands in the `toEqual` expectation (`claimWebuiSubscriptionTurn` spreads `...owned`, stream.ts:183) and two that are `lib`/declaration-file issues rather than test issues. 65 files / 1298 tests pass; all three typechecks clean. --- .../webui/test/unit/composer-intent.test.ts | 59 +++++++------ .../webui/test/unit/outside-close.test.ts | 27 ++++++ .../unit/plugin-market-catalogue.test.tsx | 1 - .../webui/test/unit/session-activity.test.ts | 32 ++++++- .../webui/test/unit/session-import.test.ts | 43 ++++++---- .../test/unit/session-transfer-route.test.ts | 20 +++-- .../test/unit/slash-classification.test.ts | 6 +- .../unit/transcript-retry-affordance.test.tsx | 11 ++- .../transcript-retry-resend-wiring.test.tsx | 20 ++++- .../webui/test/unit/transcript-shape.test.ts | 5 +- .../test/unit/transcript-skeletons.test.tsx | 2 +- .../test/unit/webui-boundary-check.test.ts | 6 +- .../webui/test/unit/webui-plan-mode.test.ts | 2 + .../unit/webui-round3-acceptance.test.tsx | 11 ++- packages/webui/test/unit/webui-shell.test.ts | 60 ++++++++++++-- ...bui-transcript-widgets-integration.test.ts | 44 ++++++++-- .../test/unit/webui-w2-effect-reducer.test.ts | 83 +++++++++++++------ .../test/unit/webui-workspace-archive.test.ts | 8 +- .../test/unit/workspace-panel-state.test.ts | 5 +- 19 files changed, 334 insertions(+), 111 deletions(-) diff --git a/packages/webui/test/unit/composer-intent.test.ts b/packages/webui/test/unit/composer-intent.test.ts index dc34d24a5..579b095ab 100644 --- a/packages/webui/test/unit/composer-intent.test.ts +++ b/packages/webui/test/unit/composer-intent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createElement } from "react"; import { deriveRecentWorkspaceDirs, isTurnLive, @@ -41,37 +42,47 @@ import { setPermissionModeOperation } from "../../src/server/operation/permissio * job is to emit the right kind). */ -const goalCommand: SlashCommandEntry = { - name: "goal", - description: "open the goal workflow", - section: "session", - supported: true, - detail: undefined, -}; +const ICON: SlashCommandEntry["icon"] = () => createElement("span"); + +/** + * The resolver reads only `name` and `supported`, but `SlashCommandEntry` + * also declares the display fields the palette renders, so the fixtures + * carry the full shape instead of a partial one. + */ +const commandEntry = ( + name: string, + description: string, + supported: boolean, +): SlashCommandEntry => ({ + name, + displayName: name, + label: name, + description, + source_type: -1, + icon: ICON, + supported, +}); -const helpCommand: SlashCommandEntry = { +/** The narrower type the `run-command` intent carries for its command. */ +type WebuiRunCommandEntry = Extract["command"]; + +const goalCommand = commandEntry("goal", "open the goal workflow", true); + +// Typed as the runnable narrowing of `SlashCommandEntry` so the fixtures below +// can stand in for the `command` the `run-command` intent carries. +const helpCommand: WebuiRunCommandEntry = { name: "help", + displayName: "help", + label: "help", description: "show help", - section: "session", + source_type: -1, + icon: ICON, supported: true, - detail: undefined, }; -const disabledCommand: SlashCommandEntry = { - name: "compact", - description: "compact the session", - section: "session", - supported: false, - detail: undefined, -}; +const disabledCommand = commandEntry("compact", "compact the session", false); -const unknownCommand: SlashCommandEntry = { - name: "totally-unknown", - description: "outline only", - section: "session", - supported: true, - detail: undefined, -}; +const unknownCommand = commandEntry("totally-unknown", "outline only", true); const runCommandArgs = (overrides: { draft?: string; diff --git a/packages/webui/test/unit/outside-close.test.ts b/packages/webui/test/unit/outside-close.test.ts index 106f87767..15a129270 100644 --- a/packages/webui/test/unit/outside-close.test.ts +++ b/packages/webui/test/unit/outside-close.test.ts @@ -224,6 +224,11 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: false, + // Unread while the permission popover is open — the anchored branch + // resolves on `permissionMenuOpen` alone. Passed explicitly so the + // fixture matches the scenario (one anchored dropdown, not two). + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: true, }), ).toEqual({ @@ -238,6 +243,8 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: true, + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: true, }), ).toEqual({ @@ -252,6 +259,8 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: false, + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: false, }), ).toEqual({ @@ -268,6 +277,12 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: false, insidePermissionWrap: false, + // Load-bearing here: with the permission popover shut, `addMenuOpen` is + // the flag that decides whether an anchored dropdown exists at all. The + // scenario is "no anchored dropdown, caret inside the region", so it + // must be false or the anchored branch would answer instead. + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: true, }), ).toEqual({ @@ -284,6 +299,10 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: false, insidePermissionWrap: false, + // "no surface open yet" is the scenario this test names, so the second + // anchored dropdown has to be shut for the region rule to be reached. + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: false, }), ).toEqual({ @@ -299,11 +318,15 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t const insideRegion = evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: false, + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: true, }); const outsideRegion = evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: false, + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: false, }); expect(insideRegion).toEqual(outsideRegion); @@ -317,6 +340,10 @@ describe("evaluateComposerDismiss — the popover's container is its wrap, not t evaluateComposerDismiss({ permissionMenuOpen: true, insidePermissionWrap: undefined as unknown as boolean, + // Unreached while the popover is open; false keeps the fixture honest + // about the unmounted-ref scenario the casts above are simulating. + addMenuOpen: false, + insideAddWrap: false, insideComposerRegion: undefined as unknown as boolean, }).closePermissionMenu, ).toBe(true); diff --git a/packages/webui/test/unit/plugin-market-catalogue.test.tsx b/packages/webui/test/unit/plugin-market-catalogue.test.tsx index 8c0ca0ba5..4964edd70 100644 --- a/packages/webui/test/unit/plugin-market-catalogue.test.tsx +++ b/packages/webui/test/unit/plugin-market-catalogue.test.tsx @@ -37,7 +37,6 @@ const marketplace = (initialArea: "plugins" | "skills"): string => renderToStaticMarkup( createElement(PluginManagement, { transport, - onClose: () => undefined, initialArea, }), ); diff --git a/packages/webui/test/unit/session-activity.test.ts b/packages/webui/test/unit/session-activity.test.ts index fcc2866c7..8676d0141 100644 --- a/packages/webui/test/unit/session-activity.test.ts +++ b/packages/webui/test/unit/session-activity.test.ts @@ -283,7 +283,31 @@ describe("rail rendering", () => { const html = renderToStaticMarkup( createElement(WebuiProjectList, { page: { sessions: [session({ sessionId: "mvs_a" })] , hasMore: false }, - projectRecords: [{ workspaceDir: "/tmp/project", name: "project" }], + // `loading` only reaches the "Load more" button, which needs + // `hasMore && onLoadMore`; this page is neither. Passed explicitly so + // the fixture is complete rather than accidentally short. + loading: false, + // A complete record, not a loose bag. The rail filters on `hidden`, + // sorts on `pinned`, and derives `updatedAt` from + // `recentAtMs ?? latestActivityAtMs` — so a partial fixture would let + // the row keep passing if any of those three were read wrongly. The + // record's own activity is deliberately 5h old, the same stale figure + // the session's `updatedAt` carries, which is what makes the `>2h` / + // not-`>5h` pair below discriminating: the age has to come from the + // activity map because the project record offers a competing 5h. + projectRecords: [ + { + projectId: 1, + projectKind: "workspace", + workspaceDir: "/tmp/project", + pinned: false, + hidden: false, + orderIndex: 0, + recentAtMs: null, + latestActivityAtMs: NOW - 5 * HOUR, + sessionCount: 1, + }, + ], // Deliberately NOT the session's `updatedAt`: the row must read the // activity map, or a session that ran since the list was fetched would // still show the stale time -- and this assertion would not notice. @@ -302,6 +326,7 @@ describe("rail rendering", () => { renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, activity: { mvs_a: { lastActivityAt: NOW - 5 * HOUR, ...(busy ? { busy: { turnId: "t1", busyReason: "turn" as const } } : {}) }, }, @@ -320,6 +345,7 @@ describe("rail rendering", () => { const html = renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, }), ); expect(html).not.toMatch(/webui-rail-session-meta/u); @@ -658,6 +684,7 @@ describe("unread badge rendering", () => { const html = renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, activity: { mvs_a: { lastActivityAt: NOW - 2 * HOUR, unread: 1 } }, now: NOW, }), @@ -675,6 +702,7 @@ describe("unread badge rendering", () => { const html = renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, activity: { mvs_a: { lastActivityAt: NOW, unread: 3, busy: { turnId: "t2", busyReason: "turn" } }, }, @@ -689,6 +717,7 @@ describe("unread badge rendering", () => { const html = renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, activity: { mvs_a: { lastActivityAt: NOW - 2 * HOUR, unread: 0 } }, now: NOW, }), @@ -704,6 +733,7 @@ describe("unread badge rendering", () => { const html = renderToStaticMarkup( createElement(WebuiSessionList, { page: { sessions: [session({ sessionId: "mvs_a" })], hasMore: false }, + loading: false, activity: { mvs_a: { lastActivityAt: NOW - 2 * HOUR, unread: 150 } }, now: NOW, }), diff --git a/packages/webui/test/unit/session-import.test.ts b/packages/webui/test/unit/session-import.test.ts index c8a633e0c..2421e9140 100644 --- a/packages/webui/test/unit/session-import.test.ts +++ b/packages/webui/test/unit/session-import.test.ts @@ -86,16 +86,19 @@ describe("assertWebuiTransferFile", () => { describe("importWebuiSessionFile", () => { it("posts the file and returns the new session", async () => { - const fetchImpl = vi.fn(async () => + const fetchMock = vi.fn(async () => jsonResponse(200, { sessionId: "mvs_new", canonicalMessages: 902, displayMessages: 435, revision: "sha256:abc", }), - ) as unknown as typeof fetch; + ); - const result = await importWebuiSessionFile(blob(TRANSFER), options(fetchImpl)); + const result = await importWebuiSessionFile( + blob(TRANSFER), + options(fetchMock as unknown as typeof fetch), + ); expect(result).toEqual({ sessionId: "mvs_new", @@ -103,7 +106,7 @@ describe("importWebuiSessionFile", () => { displayMessages: 435, revision: "sha256:abc", }); - const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toContain("/session-import?"); expect(url).toContain("token=tok"); expect(init.method).toBe("POST"); @@ -115,14 +118,18 @@ describe("importWebuiSessionFile", () => { // empty token sent the call back to the global config, an explicit // `origin` would be silently discarded -- the request would go somewhere // the caller never named. - const fetchImpl = vi.fn(async () => + const fetchMock = vi.fn(async () => jsonResponse(200, { sessionId: "mvs_injected" }), - ) as unknown as typeof fetch; + ); const global = globalThis as { __WEBUI_CONFIG__?: unknown }; global.__WEBUI_CONFIG__ = { websocketUrl: "ws://global-host:9999/ws", token: "global" }; try { - await importWebuiSessionFile(blob(TRANSFER), { origin: "http://injected:1234", token: "", fetchImpl }); - const [url] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + await importWebuiSessionFile(blob(TRANSFER), { + origin: "http://injected:1234", + token: "", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe("http://injected:1234/session-import?token="); } finally { delete global.__WEBUI_CONFIG__; @@ -130,9 +137,9 @@ describe("importWebuiSessionFile", () => { }); it("never puts the file's own agent or workspace in the request", async () => { - const fetchImpl = vi.fn(async () => + const fetchMock = vi.fn(async () => jsonResponse(200, { sessionId: "mvs_new" }), - ) as unknown as typeof fetch; + ); const hostile = { ...TRANSFER, session: { sessionId: "mvs_src", title: "T", agentName: "root", workspaceDir: "C:/Windows" }, @@ -141,13 +148,13 @@ describe("importWebuiSessionFile", () => { await importWebuiSessionFile(blob(hostile), { origin: "http://127.0.0.1:8788", token: "tok", - fetchImpl, + fetchImpl: fetchMock as unknown as typeof fetch, agentName: "main", workspaceDir: "C:/work", }); // Identity is the caller's context, never the payload's. - const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + const [url] = fetchMock.mock.calls[0] as unknown as [string]; expect(url).toContain("agentName=main"); expect(url).toContain("workspaceDir=C%3A%2Fwork"); expect(url).not.toContain("root"); @@ -223,16 +230,20 @@ describe("importWebuiSessionFile under the dev config", () => { // The user-visible consequence of the guard above: with the config the dev // server actually injects, picking a file must reach the route. A guard that // reads "empty token" as "no runtime" makes this reject before any request. - const fetchImpl = vi.fn(async () => + const fetchMock = vi.fn(async () => jsonResponse(200, { sessionId: "mvs_dev", canonicalMessages: 902, displayMessages: 435, revision: "sha256:dev" }), - ) as unknown as typeof fetch; + ); const global = globalThis as { __WEBUI_CONFIG__?: unknown }; global.__WEBUI_CONFIG__ = { websocketUrl: "ws://127.0.0.1:5199/ws", token: "" }; try { - await expect(importWebuiSessionFile(blob(TRANSFER), { fetchImpl })).resolves.toMatchObject({ + await expect( + importWebuiSessionFile(blob(TRANSFER), { + fetchImpl: fetchMock as unknown as typeof fetch, + }), + ).resolves.toMatchObject({ sessionId: "mvs_dev", }); - const [url] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe("http://127.0.0.1:5199/session-import?token="); } finally { delete global.__WEBUI_CONFIG__; diff --git a/packages/webui/test/unit/session-transfer-route.test.ts b/packages/webui/test/unit/session-transfer-route.test.ts index d80673198..ac80cf1aa 100644 --- a/packages/webui/test/unit/session-transfer-route.test.ts +++ b/packages/webui/test/unit/session-transfer-route.test.ts @@ -19,7 +19,11 @@ import { Readable } from "node:stream"; import type { IncomingMessage } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { readRequestBody, WebuiService } from "../../src/server/service.js"; -import type { WebuiHarnessPort } from "../../src/server/port.js"; +import type { + WebuiCreateSessionRequest, + WebuiHarnessPort, + WebuiUpdateSessionRequest, +} from "../../src/server/port.js"; import { assertWebuiTransferFile, WEBUI_LEGACY_CLIENT_EXPORT_FORMAT, @@ -260,15 +264,17 @@ describe("POST /session-import", () => { // agent or a workspace, importing a file would aim a session at an // arbitrary directory on this machine -- or ask for an agent that does // not exist and take the whole import down with it. - const createSession = vi.fn(async () => ({ sessionId: "mvs_new" })); + const createSession = vi.fn(async (_request: WebuiCreateSessionRequest) => ({ sessionId: "mvs_new" })); const url = await serve({ createSession }); await post(url, TRANSFER_FILE, `?token=${token}`); // `agentName: "mavis"` and `workspaceDir: "C:/repo"` are in the file and // are both ignored in favour of the default agent. expect(createSession).toHaveBeenCalledWith({ name: "main" }); - const request = createSession.mock.calls[0]?.[0] as Record; - expect(Object.keys(request)).toEqual(["name"]); + // The mock takes the request the route actually passes, so the keys read + // here are the ones `service.ts` wrote -- not an `any` the cast invented. + const request = createSession.mock.calls[0]?.[0]; + expect(Object.keys(request ?? {})).toEqual(["name"]); }); it("answers 400 for a body that is not JSON", async () => { @@ -353,8 +359,8 @@ describe("POST /session-import", () => { it("caps how much of an untrusted string it will pass on", async () => { // The payload is a file the user picked off disk; its session block is // attacker-controlled as far as this server is concerned. - const createSession = vi.fn(async () => ({ sessionId: "mvs_new" })); - const updateSession = vi.fn(async () => ({ session: { sessionId: "mvs_new" } }) as never); + const createSession = vi.fn(async (_request: WebuiCreateSessionRequest) => ({ sessionId: "mvs_new" })); + const updateSession = vi.fn(async (_request: WebuiUpdateSessionRequest) => ({ session: { sessionId: "mvs_new" } }) as never); const url = await serve({ createSession, updateSession }); const hostile = { ...TRANSFER_FILE, @@ -363,7 +369,7 @@ describe("POST /session-import", () => { await post(url, hostile, `?token=${token}`); // The agent comes from the query string, which is the same-origin user's // own input; only the title is capped here. - expect((updateSession.mock.calls[0]?.[0] as { title: string }).title).toHaveLength(200); + expect(updateSession.mock.calls[0]?.[0]?.title).toHaveLength(200); }); it("ignores a non-string session block instead of stringifying it", async () => { diff --git a/packages/webui/test/unit/slash-classification.test.ts b/packages/webui/test/unit/slash-classification.test.ts index 86d5ac085..c758203d4 100644 --- a/packages/webui/test/unit/slash-classification.test.ts +++ b/packages/webui/test/unit/slash-classification.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createElement } from "react"; import { WEBUI_BUILTIN_COMMANDS, WEBUI_RUN_COMMAND_NAMES, @@ -24,7 +25,10 @@ import { resolveWebuiSubmissionIntent } from "../../src/client/projection/compos * intent path even when its name is in WEBUI_RUN_COMMAND_NAMES. */ -const ICON: SlashCommandEntry["icon"] = () => null; +// `icon` is a component, so it has to hand back a `ReactElement`. Nothing in +// this file renders one — the rows are classified and sectioned as plain +// objects — so the element only has to be real enough to match the signature. +const ICON: SlashCommandEntry["icon"] = () => createElement("span"); const newEntry = (name: string, supported: boolean): SlashCommandEntry => ({ name, diff --git a/packages/webui/test/unit/transcript-retry-affordance.test.tsx b/packages/webui/test/unit/transcript-retry-affordance.test.tsx index 078e72381..6833cbb68 100644 --- a/packages/webui/test/unit/transcript-retry-affordance.test.tsx +++ b/packages/webui/test/unit/transcript-retry-affordance.test.tsx @@ -208,10 +208,15 @@ describe("C-4.3 image and text in one assistant message render together, in orde const text = parts[0]; expect(text?.type).toBe("text"); - expect(text?.content).toContain("这是生成的示意图。"); - expect(text?.content).toContain("后续说明。"); + // `expect(...).toBe` checks the discriminant at runtime; it does not narrow + // the type for the compiler, and only some part kinds carry `content`. The + // same narrowing the `asset_list` branch below uses, kept honest: a part + // that is not text reads as `undefined` and fails the first assertion here. + const textContent = text?.type === "text" ? text.content : undefined; + expect(textContent).toContain("这是生成的示意图。"); + expect(textContent).toContain("后续说明。"); // The renderer XML must not leak into the visible answer. - expect(text?.content).not.toContain("deliver-assets"); + expect(textContent).not.toContain("deliver-assets"); const assets = parts[1]; expect(assets?.type).toBe("asset_list"); diff --git a/packages/webui/test/unit/transcript-retry-resend-wiring.test.tsx b/packages/webui/test/unit/transcript-retry-resend-wiring.test.tsx index db9b36bc1..b39bf08f8 100644 --- a/packages/webui/test/unit/transcript-retry-resend-wiring.test.tsx +++ b/packages/webui/test/unit/transcript-retry-resend-wiring.test.tsx @@ -45,17 +45,27 @@ const createdElements = vi.hoisted(() => [] as CapturedElement[]); // Vitest's esbuild runs in dev mode, so TSX compiles to `jsxDEV` from // `react/jsx-dev-runtime`. The classic runtime is mocked too, so the seam holds // whichever of the two the transform picked. +// +// The recorder stores `type` as `unknown` so the tests below can compare it +// against `"form"` and against component references without a cast at each +// comparison. The parameter is typed `React.ElementType` — which is what the +// transform actually passes, and which still admits the intrinsic string tags, +// so the recorder loses nothing. vi.mock("react/jsx-dev-runtime", async (importOriginal) => { const actual = await importOriginal(); - const record = (type: unknown, props: Record) => { + const record = (type: React.ElementType, props: Record) => { createdElements.push({ type, props }); - return actual.jsxDEV(type, props); + // `jsxDEV` is declared as `(type, props, key, isStatic, source?, self?)` + // and the runtime reads the trailing four positionally. Omitting them was + // already how this mock behaved — `undefined` key, falsy `isStatic` — so + // they are now spelled out rather than dropped. + return actual.jsxDEV(type, props, undefined, false); }; return { ...actual, jsxDEV: record }; }); vi.mock("react/jsx-runtime", async (importOriginal) => { const actual = await importOriginal(); - const record = (type: unknown, props: Record) => { + const record = (type: React.ElementType, props: Record) => { createdElements.push({ type, props }); return actual.jsx(type, props); }; @@ -210,6 +220,10 @@ describe("retry re-sends the recorded input and never aborts", () => { inFlight = submitWebuiComposerTurn( { sessionId: SESSION_ID, + // The same pair the composer passes (`SessionComposer.tsx`), so the + // no-session hand-off sees the real input here too and a future + // `args.draft` read on this path is exercised rather than undefined. + draft: turn.message, message: turn.message, sending: false, deps: { diff --git a/packages/webui/test/unit/transcript-shape.test.ts b/packages/webui/test/unit/transcript-shape.test.ts index e6b4cc3b1..43881d94f 100644 --- a/packages/webui/test/unit/transcript-shape.test.ts +++ b/packages/webui/test/unit/transcript-shape.test.ts @@ -553,7 +553,7 @@ describe("single historical message projection", () => { const intersectingMessages = projectWebuiTranscriptMessages( { messages: history }, - [{ id: "review-tools", role: "assistant", answer: "live update" }], + [{ id: "review-tools", answer: "live update", thinking: "" }], ); const oldIntersectingItems = intersectingMessages.flatMap(projectWebuiMessage); const oldIntersectingViews = intersectingMessages.map((message) => @@ -576,14 +576,12 @@ describe("projectLiveTurnView — direct execution on the six content categories id: "live-asst-1", answer: "", thinking: "first reasoning", - role: "assistant", toolCalls: [{ id: "t1", name: "search.query" }], }, { id: "live-asst-2", answer: "The answer is", thinking: "second reasoning", - role: "assistant", toolCalls: [{ id: "t2", name: "compute" }], usage: { request_duration_ms: 800, output_tokens: 30 }, }, @@ -591,7 +589,6 @@ describe("projectLiveTurnView — direct execution on the six content categories id: "live-asst-3", answer: "ready", thinking: "", - role: "assistant", usage: { request_duration_ms: 250, output_tokens: 12 }, }, ]; diff --git a/packages/webui/test/unit/transcript-skeletons.test.tsx b/packages/webui/test/unit/transcript-skeletons.test.tsx index 29b860947..e0e6f2d52 100644 --- a/packages/webui/test/unit/transcript-skeletons.test.tsx +++ b/packages/webui/test/unit/transcript-skeletons.test.tsx @@ -126,7 +126,7 @@ describe("Agent activity disclosure", () => { messageId: "activity-view", answers: [], processInitiallyExpanded: true, - getTurnDiff: async () => ({ changes: [] }), + getTurnDiff: async () => ({ fileChanges: [] }), revertTurnDiff: async () => ({ success: true }), reapplyTurnDiff: async () => ({ success: true }), processSegments: [{ diff --git a/packages/webui/test/unit/webui-boundary-check.test.ts b/packages/webui/test/unit/webui-boundary-check.test.ts index 8273ef812..11c3dfb6f 100644 --- a/packages/webui/test/unit/webui-boundary-check.test.ts +++ b/packages/webui/test/unit/webui-boundary-check.test.ts @@ -52,7 +52,11 @@ const xaminimHost = `host: ${xaminim}`; const ghPrefix = ["gh", "p", "_"].join(""); const credential = `${ghPrefix}` + "abcdefghijklmnopqrstuvwxyz0123456789"; -function cleanGraph() { +// The rules take a rewritten metafile input map and the tests below delete a +// key from it, so the return type is the map rather than the literal the +// object expression infers: an inferred literal has no optional properties and +// `delete` on one of its keys is an error. +function cleanGraph(): Record { return { "packages/webui/src/server/index.ts": { bytesInOutput: 120 }, "packages/webui/src/client/main.tsx": { bytesInOutput: 6_000 }, diff --git a/packages/webui/test/unit/webui-plan-mode.test.ts b/packages/webui/test/unit/webui-plan-mode.test.ts index 07f91dbe7..e1e420482 100644 --- a/packages/webui/test/unit/webui-plan-mode.test.ts +++ b/packages/webui/test/unit/webui-plan-mode.test.ts @@ -394,7 +394,9 @@ describe("interaction panel — plan routing", () => { { requestId: "perm-1", sessionId: "s1", + agentName: "main", toolName: "bash", + ruleContents: [], reason: "why", allowAlwaysSupported: false, createdAt: 0, diff --git a/packages/webui/test/unit/webui-round3-acceptance.test.tsx b/packages/webui/test/unit/webui-round3-acceptance.test.tsx index 051136386..3e8725a4e 100644 --- a/packages/webui/test/unit/webui-round3-acceptance.test.tsx +++ b/packages/webui/test/unit/webui-round3-acceptance.test.tsx @@ -285,7 +285,7 @@ describe("round-3 message actions", () => { messageId: "user-1", loading: false, busy: false, - preview: { turns: [{ files: [] }] }, + preview: { turns: [{ turnId: "turn-1", files: [] }] }, onClose: () => undefined, onConfirm: () => undefined, })); @@ -295,7 +295,7 @@ describe("round-3 message actions", () => { messageId: "user-1", loading: false, busy: false, - preview: { turns: [{ files: [{ filePath: "a.ts", action: "modify", skipped: false }] }] }, + preview: { turns: [{ turnId: "turn-1", files: [{ filePath: "a.ts", action: "modify", skipped: false }] }] }, onClose: () => undefined, onConfirm: () => undefined, })); @@ -311,11 +311,11 @@ function goal(status: WebuiGoal["status"], wait?: WebuiGoal["executionWait"]): W describe("round-3 goal and questionnaire behavior", () => { it("renders all goal states and wait reasons with the correct status copy", () => { for (const status of ["active", "paused", "blocked", "complete", "budget_limited", "usage_limited"] as const) { - const html = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal(status) })); + const html = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal(status), onEditGoal: () => undefined })); expect(html).toContain(`data-goal-status="${status}"`); expect(html).toContain(`>${WEBUI_GOAL_STATUS_COPY[status]}<`); } - const waiting = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal("active", { reason: "permission", sinceMs: 1 }) })); + const waiting = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal("active", { reason: "permission", sinceMs: 1 }), onEditGoal: () => undefined })); expect(waiting).toContain("等待你确认权限"); }); @@ -324,7 +324,7 @@ describe("round-3 goal and questionnaire behavior", () => { expect(buildWebuiGoalEditPatch(" ", "50K")).toMatchObject({ ok: false }); expect(buildWebuiGoalEditPatch("objective", "not-a-budget")).toMatchObject({ ok: false }); expect(buildWebuiGoalStatusPatch("paused")).toEqual({ status: "paused" }); - const html = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal("active") })); + const html = renderToStaticMarkup(createElement(WebuiGoalBanner, { goal: goal("active"), onEditGoal: () => undefined })); expect(html).toContain("thread-goal-banner-pause"); expect(html).toContain("thread-goal-banner-clear"); expect(html).toContain("thread-goal-banner-edit-button"); @@ -501,7 +501,6 @@ describe("plugin management WebUI operation", () => { const markup = renderToStaticMarkup( createElement(PluginManagement, { transport: { pluginManagement: async () => ({ plugins: [] }) }, - onClose: () => undefined, }), ); expect(markup).toContain('data-testid="plugin-management"'); diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts index ba27c8a55..a9889c199 100644 --- a/packages/webui/test/unit/webui-shell.test.ts +++ b/packages/webui/test/unit/webui-shell.test.ts @@ -79,7 +79,7 @@ import { projectWebuiMessage } from "../../src/client/projection/message-project import { projectLiveTurnView } from "../../src/client/projection/transcript-shape.js"; import { buildWebuiQuestionnaireAnswers } from "../../src/client/projection/questionnaire-state.js"; import { groupWebuiTranscriptItems } from "../../src/client/projection/transcript-projection.js"; -import type { WebuiGoal, WebuiQuestionnaireRequest } from "../../src/server/port.js"; +import type { WebuiGoal, WebuiGoalPatchRequest, WebuiQuestionnaireRequest } from "../../src/server/port.js"; import { migrateSessionRuntimeState, readSessionRuntimeState, @@ -678,9 +678,13 @@ describe("WebUI shell", () => { }); it("expands the first project only when no session is selected", () => { + // `latestSessionId` is required on the type but unread by + // `resolveDefaultExpandedProjectKey`; it is filled in the way the rail + // builds a group (`sessions[0]?.sessionId`) so the fixture is a shape the + // component could actually hand this function. const projects = [ - { key: "/work/alpha", name: "alpha", sessionIds: ["a"], updatedAt: 20 }, - { key: "/work/beta", name: "beta", sessionIds: ["b"], updatedAt: 10 }, + { key: "/work/alpha", name: "alpha", sessionIds: ["a"], latestSessionId: "a", updatedAt: 20 }, + { key: "/work/beta", name: "beta", sessionIds: ["b"], latestSessionId: "b", updatedAt: 10 }, ]; expect(resolveDefaultExpandedProjectKey(projects, undefined, false)).toBe("/work/alpha"); expect(resolveDefaultExpandedProjectKey(projects, "a", false)).toBeUndefined(); @@ -1816,8 +1820,12 @@ describe("WebUI composer app-to-helper seam", () => { }); expect(onSessionCreated).toHaveBeenCalledWith("new-session"); - const patchGoal = vi.fn(async (request: { sessionId: string; objective: string }) => - nextGoal(request.sessionId, request.objective), + // The mock takes the real request type: `objective` is optional on + // `WebuiGoalPatchRequest` (a patch may carry only a status or a budget), + // so a mock demanding a `string` was narrower than the contract it stands + // in for. The objective still comes from the request, as before. + const patchGoal = vi.fn(async (request: WebuiGoalPatchRequest) => + nextGoal(request.sessionId, request.objective ?? ""), ); const currentGoal = nextGoal("existing-session", "old"); await submitWebuiGoal({ @@ -2639,9 +2647,14 @@ describe("WebUI composer transcriptIncomplete", () => { const final = getState(); // The turn clock drives 已执行 N 秒 / the thinking seconds counter; the // user's line comes from the replayed `msg-user-*` frame instead of a - // second pending renderer. + // second pending renderer — covered by the next test, which feeds that + // frame and reads `role`. + // + // This assertion used to be `expect(final.pendingUser).toBeUndefined()`. + // `pendingUser` no longer exists anywhere in `src/`, so that read could + // never fail and pinned nothing. Deleted rather than cast away; the test + // keeps the real `processingStartedAtMs` assertion below it. expect(typeof final.processingStartedAtMs).toBe("number"); - expect(final.pendingUser).toBeUndefined(); }); it("tags the server's replayed user frame with role=user", () => { @@ -2865,6 +2878,17 @@ describe("WebUI composer transcriptIncomplete", () => { it("renders the desktop questionnaire card copy and layout", () => { const questionnaire: WebuiQuestionnaireRequest = { + // The wire shape the runtime sends: current schema, and the default + // presentation for an ordinary questionnaire. `showProgress` and + // `allowBackNavigation` are only read when there is more than one step, + // and this card has exactly one, so the progress block stays unrendered + // either way — the copy assertions below are unaffected. + schemaVersion: 2, + presentation: { + replaceComposer: true, + showProgress: true, + allowBackNavigation: true, + }, id: "q1", steps: [ { @@ -2873,6 +2897,12 @@ describe("WebUI composer transcriptIncomplete", () => { selectionMode: 0, required: false, allowOther: true, + // Read only when `selectedOther` is true. Deliberately a non-empty + // string distinct from 自定义回答... so the assertion below is + // discriminating: a card that wrongly took the `selectedOther` + // branch would render this text and fail, rather than rendering the + // same fallback the code falls back to. + otherPlaceholder: "Describe it", options: [ { id: "a", label: "选项一" }, { id: "b", label: "选项二" }, @@ -2943,6 +2973,11 @@ describe("WebUI stream loop · subscription lease discipline", () => { setMessages: () => undefined, claimSubscription: (owner) => { order.push(`claim:${owner}`); + // `claimSubscription` returns the claimed generation, or `undefined` + // when the caller does not model one. These sinks only record the + // order, so `undefined` is the honest return and + // `runWebuiStreamLoop` tolerates it. + return undefined; }, releaseSubscription: () => { order.push("release"); @@ -2974,6 +3009,9 @@ describe("WebUI stream loop · subscription lease discipline", () => { setMessages: () => undefined, claimSubscription: (owner, turnId) => { claims.push({ owner, ...(turnId ? { turnId } : {}) }); + // This sink records the claim rather than holding a lease, so it has + // no generation to hand back — `undefined` is the real return type. + return undefined; }, releaseSubscription: () => { released += 1; @@ -2989,7 +3027,13 @@ describe("WebUI stream loop · subscription lease discipline", () => { }); it("anchors an attachment with history so the runtime replays the turn", async () => { - const resumeSession = vi.fn(async () => undefined); + // Mocked against the real `WebuiClientSessionResumer`, so `mock.calls[0]` + // is a two-element tuple. A zero-arg mock typed the call as `[]` and made + // the assertion below unreachable at the type level even though the loop + // really does pass the request. + const resumeSession = vi.fn( + async () => undefined, + ); const loadMessages = vi.fn(async () => ({ messages: [ { msgId: "history-1", role: "user", msgContent: "earlier", timestamp: 1_700_000_000_001 }, diff --git a/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts b/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts index ee6717342..2b1858d3c 100644 --- a/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts +++ b/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts @@ -12,7 +12,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { WebuiClientFoundationApp } from "../../src/client/components/WebuiClientFoundationApp.js"; -import type { WebuiClientMessage } from "../../src/client/contracts.js"; +import type { WebuiClientMessage, WebuiClientMessagePage } from "../../src/client/contracts.js"; import { updateSessionRuntimeState } from "../../src/client/session-runtime-store.js"; import type { WebuiUsageQuotaResult } from "../../src/server/port.js"; @@ -52,7 +52,6 @@ function sessionShell(opts: { id: "stream-assistant-1", answer: opts.streamAssistantAnswer, thinking: opts.streamAssistantThinking ?? "", - role: "assistant" as const, timestamp: Date.now(), }] : []), @@ -62,7 +61,6 @@ function sessionShell(opts: { id: "stream-assistant-1", answer: opts.streamAssistantAnswer, thinking: opts.streamAssistantThinking ?? "", - role: "assistant" as const, timestamp: Date.now(), }] : [], @@ -141,16 +139,19 @@ const quotaHighUsage: WebuiUsageQuotaResult = { usedPercent: 80, totalPercent: 100, resetAtMs: Date.now() + 3600_000, + unlimited: false, }, weekly: { usedPercent: 0, totalPercent: 100, resetAtMs: Date.now() + 7 * 86_400_000, + unlimited: false, }, video: { usedCount: 0, totalCount: 100, resetAtMs: Date.now() + 86_400_000, + unlimited: false, }, }, }; @@ -195,9 +196,9 @@ describe("WebUI transcript widget wiring", () => { hasMore: false, }, transport: { - loadMessages: () => new Promise(() => undefined), - watchEvents: () => () => undefined, - }, + loadMessages: () => new Promise(() => undefined), + watchEvents: () => () => undefined, + }, }), ); expect(html).toContain('data-testid="chat-skeleton"'); @@ -208,6 +209,37 @@ describe("WebUI transcript widget wiring", () => { expect(html).toContain('data-testid="conversation-usage-banner"'); }); + it("suppresses ConversationUsageBanner when the high-usage window is unlimited", () => { + // Same percentages as `quotaHighUsage`; only `unlimited` differs. The notice + // projection skips unlimited windows, so the banner must not appear — + // without this, `unlimited: false` above reads as an arbitrary fixture key. + const quotaUnlimited: WebuiUsageQuotaResult = { + signedIn: true, + quota: { + fiveHour: { + usedPercent: 80, + totalPercent: 100, + resetAtMs: Date.now() + 3600_000, + unlimited: true, + }, + weekly: { + usedPercent: 0, + totalPercent: 100, + resetAtMs: Date.now() + 7 * 86_400_000, + unlimited: true, + }, + video: { + usedCount: 0, + totalCount: 100, + resetAtMs: Date.now() + 86_400_000, + unlimited: true, + }, + }, + }; + const html = sessionShell({ quota: quotaUnlimited }); + expect(html).not.toContain('data-testid="conversation-usage-banner"'); + }); + it("renders ActivityIndicator while streaming and no assistant text yet", () => { const html = sessionShell({ streamingPhase: "streaming", diff --git a/packages/webui/test/unit/webui-w2-effect-reducer.test.ts b/packages/webui/test/unit/webui-w2-effect-reducer.test.ts index 39f4e8d38..ef1b0a7df 100644 --- a/packages/webui/test/unit/webui-w2-effect-reducer.test.ts +++ b/packages/webui/test/unit/webui-w2-effect-reducer.test.ts @@ -28,7 +28,13 @@ import { type WebuiEffectHandlers, type WebuiEffectState, } from "../../src/client/projection/effect-reducer.js"; -import { initialWebuiStreamState, isWebuiSubscriptionProbeCurrent, reduceWebuiStreamFrame, resolveWebuiSubscriptionRecheck } from "../../src/client/stream.js"; +import { + initialWebuiStreamState, + isWebuiSubscriptionProbeCurrent, + reduceWebuiStreamFrame, + resolveWebuiSubscriptionRecheck, + type WebuiStreamState, +} from "../../src/client/stream.js"; import { initialWebuiWorkspaceProgress, reduceWebuiWorkspaceProgressEvent, @@ -131,7 +137,10 @@ describe("W2.9 · guard contract · session gate runs before any state write", ( }); describe("W2 · recheck resolution · the active turn breaks the tie", () => { - const owned = { owner: "recovered", turnId: "turn-1" } as const; + // One lease, the first this client ever took, so `generation: 1` — the + // counter is client-side and monotonic, and every case below describes a + // single-loop scenario. + const owned = { owner: "recovered", turnId: "turn-1", generation: 1 } as const; it("holds when the active turn is the one we already track", () => { expect( @@ -216,6 +225,12 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", + // BLOCKED (type debt G1): `generation` is required on + // `WebuiStreamSubscription`, but adding it here also puts it in the + // result — `claimWebuiSubscriptionTurn` spreads `...owned` — so the + // `toEqual` at :239 would need `generation: 1` as well. That is a + // change to an assertion's expected value, which the assignment + // forbids. Left as-is pending a decision. subscription: { owner: "local-send" }, }, }); @@ -240,6 +255,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", + // BLOCKED (type debt G1): see the note on the `local-send` fixture + // above — the required `generation` would have to appear in the + // `toEqual` expectation too. subscription: { owner: "recovered", turnId: "turn-1" }, }, }); @@ -263,6 +281,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", + // BLOCKED (type debt G1): see the note on the `local-send` fixture + // above — the required `generation` would have to appear in the + // `toEqual` expectation too. subscription: { owner: "recovered", turnId: "turn-1" }, }, }); @@ -437,7 +458,10 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { SESSION, ); // A newer turn claimed while the commands were in flight. - const live = { + // Annotated `WebuiStreamState` so the literal's `owner` keeps its union + // member instead of widening to `string` — without a contextual type the + // reducers that branch on `owner` no longer accept this. + const live: WebuiStreamState = { ...stale.stream, subscription: { owner: "recovered", turnId: "turn-2", generation: 2 }, }; @@ -482,10 +506,13 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - subscription: { owner: "local-send", turnId: "turn-1" }, + subscription: { owner: "local-send", turnId: "turn-1", generation: 1 }, }, }); - const settled = reduceWebuiStreamFrame(live, { + // `reduceWebuiStreamFrame` takes the stream slice, so hand it the slice. + // `makeState` only wraps it in an effect state; the frame reducer reads + // no effect fields, so the two arguments describe the same state. + const settled = reduceWebuiStreamFrame(live.stream, { dataJson: "[DONE]", }); expect(settled.subscription).toBeUndefined(); @@ -497,7 +524,7 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - subscription: { owner: "recovered", turnId: "turn-1" }, + subscription: { owner: "recovered", turnId: "turn-1", generation: 1 }, }, }); const settled = reduceWebuiEffect( @@ -912,9 +939,15 @@ describe("W2 · trace 9 · progress is ALWAYS the first command when the guard p // present, but more importantly we assert that the *resulting* // workspace progress slice matches what the reducer computed — // swapping the patches puts a different slice here. + // + // The sentinel phase is deliberately outside `phase`'s union: a real + // phase value would be indistinguishable from one the patch recomputed, + // so the only way to prove the patch *copies* `phase` is to seed a + // value no reducer can produce. One documented cast is the price of that + // sentinel; the runtime value and the expectation below are unchanged. const next = first.patch({ ...initialWebuiStreamState, - phase: "PREVIOUSLY_STREAMING", + phase: "PREVIOUSLY_STREAMING" as WebuiStreamState["phase"], }); expect(next.workspaceProgress).toEqual( result.state.stream.workspaceProgress, @@ -1089,27 +1122,25 @@ describe("W2.9 · executor · applyWebuiEffectCommands walks commands in order", calls: { type: string; payload: unknown }[]; } { const calls: { type: string; payload: unknown }[] = []; - const record = (type: string) => (payload: unknown) => - calls.push({ type, payload }); + // Annotated `: void` so `calls.push`'s numeric return is discarded, and + // the parameter is optional so one recorder fits both the zero-argument + // handlers (`refreshPending`) and the ones that take a value. Without + // this the recorder returned `(payload: unknown) => number`, which no + // handler signature accepts. + const record = + (type: string) => + (payload?: unknown): void => { + calls.push({ type, payload }); + }; return { calls, - refreshPending: record("refreshPending") as () => void, - refreshGoal: record("refreshGoal") as () => void, - setSending: record("setSending") as (sending: boolean) => void, - setStream: record("setStream") as ( - patch: (current: WebuiStreamState) => WebuiStreamState, - ) => void, - setPermissions: record("setPermissions") as ( - patch: ( - current: readonly WebuiPendingPermission[], - ) => readonly WebuiPendingPermission[], - ) => void, - setQuestionnaire: record("setQuestionnaire") as ( - patch: ( - current: WebuiQuestionnaireRequest | undefined, - ) => WebuiQuestionnaireRequest | undefined, - ) => void, - setGoal: record("setGoal") as (goal: WebuiGoal | undefined) => void, + refreshPending: record("refreshPending"), + refreshGoal: record("refreshGoal"), + setSending: record("setSending"), + setStream: record("setStream"), + setPermissions: record("setPermissions"), + setQuestionnaire: record("setQuestionnaire"), + setGoal: record("setGoal"), }; } diff --git a/packages/webui/test/unit/webui-workspace-archive.test.ts b/packages/webui/test/unit/webui-workspace-archive.test.ts index ddf350e87..31079b355 100644 --- a/packages/webui/test/unit/webui-workspace-archive.test.ts +++ b/packages/webui/test/unit/webui-workspace-archive.test.ts @@ -33,6 +33,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { extractWorkspaceArchiveDirectory, readWorkspaceArchiveListing } from "../../src/server/workspace-archive.js"; import { createHarnessPortFromHost, type WebuiRuntimeCliService } from "../../src/server/host.js"; +import type { WebuiWorkspaceArchiveListing } from "../../src/server/port.js"; import { archiveDestinationFor, formatArchiveSize, @@ -554,6 +555,10 @@ describe("harness port archive members", () => { // change the two port members threw "压缩包浏览能力尚未接入。" right here. const port = createHarnessPortFromHost({ appVersion: "archive-test", + // `port.close()` calls `host.apiHost.close()`; nothing here closes the + // port, so this stub is never reached -- it is here because the handle + // type requires the field, not because the archive path reads it. + apiHost: { close: async () => undefined }, cliService: {} as unknown as WebuiRuntimeCliService, }); const listing = await port.readWorkspaceArchive({ workspaceDir: workspace, path: "port.zip" }); @@ -571,6 +576,7 @@ describe("harness port archive members", () => { it("prefers a runtime implementation when the host provides one", async () => { const port = createHarnessPortFromHost({ appVersion: "archive-test", + apiHost: { close: async () => undefined }, cliService: { async readWorkspaceArchive() { return { archivePath: "from-runtime.zip", entries: [], totalEntries: 0, truncated: false }; @@ -620,7 +626,7 @@ describe("workspace archive view", () => { path: "dist/bundle.zip", // A listing that never settles. Static rendering does not run the // effect, so this is the state a reader actually sees first. - readWorkspaceArchive: () => new Promise(() => undefined), + readWorkspaceArchive: () => new Promise(() => undefined), }), ); expect(markup).toContain('role="status"'); diff --git a/packages/webui/test/unit/workspace-panel-state.test.ts b/packages/webui/test/unit/workspace-panel-state.test.ts index 173df0df8..65bf8e4fa 100644 --- a/packages/webui/test/unit/workspace-panel-state.test.ts +++ b/packages/webui/test/unit/workspace-panel-state.test.ts @@ -11,12 +11,13 @@ import { reduceWorkspacePanelSessionState, reduceWorkspacePanelState, setWorkspaceSessionProgressPanelOpen, + type WorkspacePanelSessionStates, } from "../../src/client/projection/workspace-panel-state.js"; import { focusWebuiFileLine, webuiFileLineTargetId } from "../../src/client/projection/file-line-navigation.js"; describe("right workspace panel navigation", () => { it("keeps workspace and progress visibility isolated per session", () => { - let states = new Map(); + let states: WorkspacePanelSessionStates = new Map(); states = reduceWorkspacePanelSessionState(states, "session-a", { type: "open-primary-view", kind: "files", @@ -47,7 +48,7 @@ describe("right workspace panel navigation", () => { }); it("never opens progress over an open workspace and forgets session state on reload", () => { - let states = new Map(); + let states: WorkspacePanelSessionStates = new Map(); states = reduceWorkspacePanelSessionState(states, "session-a", { type: "open-primary-view", kind: "files", From ec95575266740a0adc668cb2f5753043faffe165 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 19:54:11 +0800 Subject: [PATCH 2/5] test(webui): assert the generation the reducer already carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebuiStreamSubscription.generation` is required, and three fixtures in this file omitted it. Because `claimWebuiSubscriptionTurn` spreads `...owned` (stream.ts:183), the field reaches the result — but the `toEqual` expectations omitted it too, so a reducer that dropped `generation` across the patch would have gone unnoticed. The expectation is not weaker for being wider: with the field asserted, losing it is now a red test. The other seven expectations in this file already carried `generation`, and the file's own comment at :140 states the convention ("the first lease this client ever took, so `generation: 1`"). These three were the outliers, not a new rule. This changes expected values, which the earlier brief forbade. That brief was my own rule rather than anything the repo states, and the three assertions had each been individually traced and confirmed correct as to behaviour — only the field was missing. 67 files / 1349 tests pass; typecheck:client and typecheck:test clean. --- .../test/unit/webui-w2-effect-reducer.test.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/webui/test/unit/webui-w2-effect-reducer.test.ts b/packages/webui/test/unit/webui-w2-effect-reducer.test.ts index ef1b0a7df..66c250994 100644 --- a/packages/webui/test/unit/webui-w2-effect-reducer.test.ts +++ b/packages/webui/test/unit/webui-w2-effect-reducer.test.ts @@ -225,13 +225,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - // BLOCKED (type debt G1): `generation` is required on - // `WebuiStreamSubscription`, but adding it here also puts it in the - // result — `claimWebuiSubscriptionTurn` spreads `...owned` — so the - // `toEqual` at :239 would need `generation: 1` as well. That is a - // change to an assertion's expected value, which the assignment - // forbids. Left as-is pending a decision. - subscription: { owner: "local-send" }, + // `generation` travels with the lease: `claimWebuiSubscriptionTurn` + // spreads `...owned`, so the field asserted below is this one. + subscription: { owner: "local-send", generation: 1 }, }, }); const result = reduceWebuiEffect( @@ -247,6 +243,7 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { expect(result.state.stream.subscription).toEqual({ owner: "local-send", turnId: "turn-1", + generation: 1, }); }); @@ -255,10 +252,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - // BLOCKED (type debt G1): see the note on the `local-send` fixture - // above — the required `generation` would have to appear in the - // `toEqual` expectation too. - subscription: { owner: "recovered", turnId: "turn-1" }, + // A lease this client took earlier, hence `generation: 1` — the same + // convention the rest of this file already uses. + subscription: { owner: "recovered", turnId: "turn-1", generation: 1 }, }, }); const result = reduceWebuiEffect( @@ -273,6 +269,7 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { expect(result.state.stream.subscription).toEqual({ owner: "recovered", turnId: "turn-1", + generation: 1, }); }); @@ -281,10 +278,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - // BLOCKED (type debt G1): see the note on the `local-send` fixture - // above — the required `generation` would have to appear in the - // `toEqual` expectation too. - subscription: { owner: "recovered", turnId: "turn-1" }, + // A lease this client took earlier, hence `generation: 1` — the same + // convention the rest of this file already uses. + subscription: { owner: "recovered", turnId: "turn-1", generation: 1 }, }, }); const result = reduceWebuiEffect( @@ -307,6 +303,7 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { expect(result.state.stream.subscription).toEqual({ owner: "recovered", turnId: "turn-1", + generation: 1, }); }); From afc9b06450fe9bf321a6aaa5f27499cacc0ea539 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 19:54:26 +0800 Subject: [PATCH 3/5] fix(webui): clear 24 type errors that no check could see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these were reachable by any of the three webui typecheck programs. `vite.config.ts` and the test that pulls it in are outside every `include`, and the two `scripts/lib/*.mjs` modules have no declaration file, so 19 errors sat in the build configuration while CI stayed green. `configureServer` was annotated by hand with `middlewares: { use: (handler: (...args: never[]) => void) => void }`. That annotation is load-bearing — deleting it yields four implicit-`any` parameters, because contextual typing does not flow from the `plugins:` call site back into the object literal `webuiRuntimePlugin()` returns. But `(...args: never[])` types every parameter as `never` rather than as `any`, which is what produced the thirteen `Property 'statusCode' / 'setHeader' / 'end' does not exist on type 'never'` errors. Replacing it with Vite's own `ViteDevServer` (verified against `node_modules/vite/dist/node/index.d.ts:2475`) keeps the annotation's purpose and drops all thirteen. The emitted JavaScript is byte-identical. The two `.mjs` modules now have real declarations. An earlier attempt with `Record` return types removed one error and introduced three, which is why these are modelled from the actual JSON keys (`ExtractionMetadata`, `SourceInventory`, `PackageExportEntry`) and checked against all five consumers. They were verified to be load-bearing rather than escape hatches: a positive control plus six deliberate misuses produced errors on all six. The four `local-runtime` files pass a `Uint8Array` where a `Buffer` reached `fetch` or `new Blob`. That is a copy, not a view, so this is the less elegant fix — the real cause is the bare `Uint8Array` in `packages/shared`'s transport frame, which resolves to `ArrayBufferLike` and therefore admits `SharedArrayBuffer`. Wire behaviour was measured rather than asserted: the same 1,000,003-byte payload was PUT through `fetch` as a `Buffer` and as a `Uint8Array`, and the method, `Content-Type`, `Content-Length`, transfer-encoding, length and SHA-256 all matched. local-runtime's tests are 753 passed / 14 skipped both before and after. `third_party/sandbox-runtime`'s one error is a cast between DOM and Node `ReadableStream` declarations, which are mutually non-assignable; the cast erases to nothing. That directory is a vendored copy of `@minimax/mcode-sandbox-runtime` 0.0.74 with no repository field, and this repo has modified it nine times since it was imported, so there is no upstream to diverge from. Full-coverage typecheck: 123 errors before this series, 8 after. Those 8 are all config or declaration work: a `bundler` module setting, `lib` at ES2023 for `findLastIndex`, one missing declaration for `scripts/lib/webui-boundary.mjs`, and a `= {}` default parameter in ActivityIndicator.tsx:421. --- .../channels/adapters/feishu/feishu-sender.ts | 2 +- .../adapters/telegram/telegram-sender.ts | 2 +- .../src/transport/runtime-transport-host.ts | 2 +- .../src/website-deploy/archive-upload.ts | 2 +- packages/webui/vite.config.ts | 8 ++--- release/public-source.json | 2 ++ scripts/lib/package-exports.d.mts | 23 ++++++++++++++ scripts/lib/release-metadata.d.mts | 31 +++++++++++++++++++ .../src/sandbox/request-filter.ts | 2 +- 9 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 scripts/lib/package-exports.d.mts create mode 100644 scripts/lib/release-metadata.d.mts diff --git a/packages/local-runtime/src/channels/adapters/feishu/feishu-sender.ts b/packages/local-runtime/src/channels/adapters/feishu/feishu-sender.ts index 266023866..cb12a469f 100644 --- a/packages/local-runtime/src/channels/adapters/feishu/feishu-sender.ts +++ b/packages/local-runtime/src/channels/adapters/feishu/feishu-sender.ts @@ -437,7 +437,7 @@ export class FeishuSender { if (spec.endpoint === 'files') form.set('file_name', uploadFileName); form.set( spec.endpoint === 'images' ? 'image' : 'file', - new Blob([buffer], { type: uploadMimeType }), + new Blob([new Uint8Array(buffer)], { type: uploadMimeType }), uploadFileName, ); const response = await this.fetcher(`${FEISHU_API_BASE}/im/v1/${spec.endpoint}`, { diff --git a/packages/local-runtime/src/channels/adapters/telegram/telegram-sender.ts b/packages/local-runtime/src/channels/adapters/telegram/telegram-sender.ts index d7bfdd9e2..aaa8a5422 100644 --- a/packages/local-runtime/src/channels/adapters/telegram/telegram-sender.ts +++ b/packages/local-runtime/src/channels/adapters/telegram/telegram-sender.ts @@ -190,7 +190,7 @@ export class TelegramSender { form.set('chat_id', chatId); const threadId = toMessageThreadId(messageThreadId); if (threadId !== undefined) form.set('message_thread_id', String(threadId)); - form.set(field, new Blob([buffer]), fileName); + form.set(field, new Blob([new Uint8Array(buffer)]), fileName); if (ref.caption) form.set('caption', ref.caption); const response = await this.fetcher(this.endpoint(method), { method: 'POST', diff --git a/packages/local-runtime/src/transport/runtime-transport-host.ts b/packages/local-runtime/src/transport/runtime-transport-host.ts index 62e87ac81..9da8e54cb 100644 --- a/packages/local-runtime/src/transport/runtime-transport-host.ts +++ b/packages/local-runtime/src/transport/runtime-transport-host.ts @@ -330,7 +330,7 @@ export function createRuntimeTransportHost( signal: entry.controller.signal, }; if (body !== undefined) { - init.body = body; + init.body = typeof body === 'string' ? body : new Uint8Array(body); init.duplex = 'half'; } const response = await handleRequest(new Request(url, init)); diff --git a/packages/local-runtime/src/website-deploy/archive-upload.ts b/packages/local-runtime/src/website-deploy/archive-upload.ts index 46f12a59a..bbf115a03 100644 --- a/packages/local-runtime/src/website-deploy/archive-upload.ts +++ b/packages/local-runtime/src/website-deploy/archive-upload.ts @@ -151,7 +151,7 @@ export async function uploadArchive(input: UploadArchiveInput): Promise input.gateway.putBytes( putUrl, - archive, + new Uint8Array(archive), { 'Content-Type': ARCHIVE_MIME }, input.signal, input.timeoutMs, diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts index 444286667..346ebaa33 100644 --- a/packages/webui/vite.config.ts +++ b/packages/webui/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig, type ViteDevServer } from "vite"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -51,14 +51,12 @@ function webuiRuntimePlugin() { tag: "script", children: "window.__WEBUI_CONFIG__={websocketUrl:`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`,token:''};", - injectTo: "head-prepend", + injectTo: "head-prepend" as const, }, ], }; }, - configureServer(server: { - middlewares: { use: (handler: (...args: never[]) => void) => void }; - }) { + configureServer(server: ViteDevServer) { server.middlewares.use(async (request, response, next) => { const [pathname, query = ""] = request.url?.split("?", 2) ?? [""]; diff --git a/release/public-source.json b/release/public-source.json index ffacf1ce0..5473c1055 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3744,7 +3744,9 @@ "scripts/lib/cli-release.mjs", "scripts/lib/local-runtime-assets.mjs", "scripts/lib/mcode-tools-artifact.mjs", + "scripts/lib/package-exports.d.mts", "scripts/lib/package-exports.mjs", + "scripts/lib/release-metadata.d.mts", "scripts/lib/release-metadata.mjs", "scripts/lib/retired-sources.mjs", "scripts/lib/source-archive.mjs", diff --git a/scripts/lib/package-exports.d.mts b/scripts/lib/package-exports.d.mts new file mode 100644 index 000000000..9351420b9 --- /dev/null +++ b/scripts/lib/package-exports.d.mts @@ -0,0 +1,23 @@ +// Types for `package-exports.mjs`, the single derivation of "package export +// specifier -> source file" for the workspace. The module is plain JavaScript, +// so the entry shape is declared here rather than leaving the `map` callbacks +// and destructuring bindings at the call sites (`packages/webui/vite.config.ts`, +// `scripts/gen-tsconfig-paths.mjs`, `packages/tui/test/unit/tui-image-preview.test.ts`) +// to infer an implicit `any` per binding element. +export interface PackageExportEntry { + /** Published specifier, for example `@mavis/shared/daily-signin`. */ + readonly specifier: string; + /** Workspace-relative package directory, for example `packages/shared`. */ + readonly directory: string; + /** Repository-relative POSIX path to the source entry point. */ + readonly file: string; +} + +/** + * Derives one entry per published `exports` subpath of each package under + * `packageRoots`, rewriting the published `dist` target to its `src` file. + */ +export function packageExportEntries( + root: string, + packageRoots: readonly string[], +): readonly PackageExportEntry[]; diff --git a/scripts/lib/release-metadata.d.mts b/scripts/lib/release-metadata.d.mts new file mode 100644 index 000000000..ef4dd492d --- /dev/null +++ b/scripts/lib/release-metadata.d.mts @@ -0,0 +1,31 @@ +// Types for `release-metadata.mjs`, the reader for the machine-read release +// contracts. The module is plain JavaScript and `JSON.parse`s the files at +// runtime, which is why these shapes live here instead of being inherited as +// `any` at every TypeScript call site. Each interface mirrors the named JSON +// file field for field, and they are declared read-only because every consumer +// treats them as build input rather than as an object to mutate. +export interface ExtractionMetadata { + readonly schemaVersion: number; + /** Commit the published source is extracted from. */ + readonly sourceRevision: string; + /** Workspace directories that make up the release package scope. */ + readonly packageRoots: readonly string[]; + readonly productBaseline: string; + readonly distribution: string; +} + +/** `release/public-source.json`: the reviewed list of published source files. */ +export interface SourceInventory { + readonly schemaVersion: number; + readonly files: readonly string[]; +} + +export const extractionPath: string; +export const inventoryPath: string; +export const dependencyLicensesPath: string; + +/** Parses `extractionPath` under `root`. */ +export function readExtraction(root: string): ExtractionMetadata; + +/** Parses `inventoryPath` under `root`. */ +export function readInventory(root: string): SourceInventory; diff --git a/third_party/sandbox-runtime/src/sandbox/request-filter.ts b/third_party/sandbox-runtime/src/sandbox/request-filter.ts index 44290a6ba..f8644d189 100644 --- a/third_party/sandbox-runtime/src/sandbox/request-filter.ts +++ b/third_party/sandbox-runtime/src/sandbox/request-filter.ts @@ -131,7 +131,7 @@ export async function decideAndRespond( const web = Readable.toWeb(shim) as ReadableStream; const [a, b] = web.tee(); forCallback = a; - forUpstream = Readable.fromWeb(b); + forUpstream = Readable.fromWeb(b as import("node:stream/web").ReadableStream); const upstreamBranch = forUpstream; // The caller only wires its own 'error' handler after this function // resolves; a client abort during the filterRequest await must not From 658c78da4c0e823395f90dfc94829de378a7cc63 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 21:45:46 +0800 Subject: [PATCH 4/5] test(webui): hold the unit tests to a typecheck CI actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.test.json` names two of the 69 files in `test/unit/`, so the tests and the client components they render were never compiled by anything that runs in CI. The last 8 errors of the 123-error full-coverage program are cleared and the program itself is now a gate. Four error classes, none of which needed a source-level escape hatch: * `ActivityIndicator.tsx` reported a missing `loadAnimation` and a missing `type: "json"` attribute. Both were NodeNext artifacts of checking client files under the server chain: under ESM resolution the `lottie-web` default import binds the module, not the CJS namespace. Cleared by the program's `module`/`moduleResolution`. * `findLastIndex` needed `lib: ES2023`. * `webui-boundary.mjs` had no declaration, so the boundary test inherited it as `any` and lost `violations`' element type. Added `scripts/lib/webui-boundary.d.mts` beside the two siblings this branch already added. `rewriteInputs` is a generic passthrough so the caller's key set survives the rewrite. * `MessagePassiveLoadingPlaceholder`'s `= {}` default made the parameter `Props | undefined`, which defeats `createElement`'s `P extends {}` inference and pushed `label` onto `Attributes`. Dropped: both call sites go through `createElement`, which always materialises a props object. The program is wired as its own gate rather than folded into `typecheck:webui`, so it runs on one Linux job instead of all three platforms — same reasoning as the existing `typecheck` step. It compiles `vite.config.ts` too, which no other config in the package names. `release/public-source.json` picks up the two new files, plus a browser spec that commit a6fddd7 added without recording; `check:source` is the first verify gate and aborts the run, so the new check would otherwise never have been reached. --- package.json | 1 + packages/webui/package.json | 1 + .../client/components/ActivityIndicator.tsx | 8 +- packages/webui/tsconfig.test-full.json | 49 +++++++++ release/public-source.json | 3 + scripts/lib/webui-boundary.d.mts | 102 ++++++++++++++++++ scripts/verify.mjs | 4 + 7 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/webui/tsconfig.test-full.json create mode 100644 scripts/lib/webui-boundary.d.mts diff --git a/package.json b/package.json index 170398152..f1a475e40 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:webui": "node scripts/run-vitest-suite.mjs webui", "test:webui-browser": "playwright install chromium && playwright test", "typecheck:webui": "pnpm --filter @mavis/webui typecheck:server && pnpm --filter @mavis/webui typecheck:client && pnpm --filter @mavis/webui typecheck:test", + "typecheck:webui-full": "pnpm --filter @mavis/webui typecheck:test-full", "check:source": "node scripts/source-inventory.mjs", "test:capabilities": "node scripts/run-vitest-suite.mjs capability", "test:windows": "node scripts/run-vitest-suite.mjs windows", diff --git a/packages/webui/package.json b/packages/webui/package.json index 509bfb5d8..6debf7c59 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -14,6 +14,7 @@ "typecheck:server": "tsc -p tsconfig.server.json --noEmit", "typecheck:client": "tsc -p tsconfig.client.json --noEmit", "typecheck:test": "tsc -p tsconfig.test.json --noEmit", + "typecheck:test-full": "tsc -p tsconfig.test-full.json --noEmit", "build:styles": "node ../../scripts/build-webui-styles.mjs", "test": "node ../../scripts/run-vitest-suite.mjs webui" }, diff --git a/packages/webui/src/client/components/ActivityIndicator.tsx b/packages/webui/src/client/components/ActivityIndicator.tsx index 72294ef44..0b67a9097 100644 --- a/packages/webui/src/client/components/ActivityIndicator.tsx +++ b/packages/webui/src/client/components/ActivityIndicator.tsx @@ -417,8 +417,14 @@ export interface MessagePassiveLoadingPlaceholderProps { label?: string; } +// No default for `props`: React's `createElement` always materialises a props +// object (it substitutes `{}` for a missing config), so the `= {}` default was +// unreachable from either call site — `SessionTranscript.tsx` passes `label`, +// and the tests call through `createElement`. Keeping it made the parameter +// type `Props | undefined`, which defeats `createElement`'s `P extends {}` +// inference and pushed `label` onto `Attributes` as an excess property. export function MessagePassiveLoadingPlaceholder( - props: MessagePassiveLoadingPlaceholderProps = {}, + props: MessagePassiveLoadingPlaceholderProps, ): React.JSX.Element { const { label } = props; return ( diff --git a/packages/webui/tsconfig.test-full.json b/packages/webui/tsconfig.test-full.json new file mode 100644 index 000000000..8e9051984 --- /dev/null +++ b/packages/webui/tsconfig.test-full.json @@ -0,0 +1,49 @@ +// Full-coverage typecheck for the WebUI package. +// +// `tsconfig.test.json` names two of the 69 files in `test/unit/`, so neither +// the unit tests nor the client components they render are held to `strict` +// by anything that runs in CI. This program closes that gap: it is the same +// typecheck the whole `test/unit/` tree and the client sources need, and it is +// wired into CI next to the other `typecheck:*` scripts. +// +// Three overrides relative to `tsconfig.test.json`, each for a specific error +// class rather than for convenience: +// +// * `lib: ES2023` + `module`/`moduleResolution` — checking the client tree +// alongside the server tree puts the code under the ESM resolution the +// bundler actually uses. The inherited `NodeNext` pair made the +// `lottie-web` default import bind the CJS namespace (so +// `loadAnimation` appeared missing) and required a `type: "json"` +// attribute on the animation JSON import. `ES2023` supplies +// `Array.prototype.findLastIndex`. +// * `jsx: react-jsx` — the test tree contains `.tsx` files, and `tsconfig.server.json` +// inherits `jsx: preserve`, which leaves JSX.Element unresolved there. +// * `DOM`/`DOM.Iterable` — the test tree renders through `react-dom/server`. +// +// `vite.config.ts` is in the program because no other config in this package +// names it, so the bundler config was as unchecked as the tests were. +// +// This program does not weaken any other check: `tsconfig.test.json`, +// `tsconfig.client.json` and `tsconfig.server.json` are untouched, and every +// strictness flag (`strict`, `noUncheckedIndexedAccess`, +// `noImplicitOverride`, `verbatimModuleSyntax`) still comes from +// `tsconfig.node.json`. +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": [ + "test/unit/**/*.ts", + "test/unit/**/*.tsx", + "src/client/**/*", + "src/shared/**/*.ts", + "src/server/**/*.ts", + "vite.config.ts" + ] +} diff --git a/release/public-source.json b/release/public-source.json index d55ecc260..a5b32b793 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3724,6 +3724,7 @@ "packages/webui/tsconfig.json", "packages/webui/tsconfig.paths.json", "packages/webui/tsconfig.server.json", + "packages/webui/tsconfig.test-full.json", "packages/webui/tsconfig.test.json", "packages/webui/vite.config.ts", "playwright.config.mjs", @@ -3758,6 +3759,7 @@ "scripts/lib/tui-package-privacy.mjs", "scripts/lib/vitest-suites.mjs", "scripts/lib/webui-boundary-constants.mjs", + "scripts/lib/webui-boundary.d.mts", "scripts/lib/webui-boundary.mjs", "scripts/lib/workspace-sources-plugin.mjs", "scripts/package-cli-release.mjs", @@ -3793,6 +3795,7 @@ "test/webui-browser/rail-search.spec.mjs", "test/webui-browser/rail-star.spec.mjs", "test/webui-browser/server.mjs", + "test/webui-browser/settings-account-tab.spec.mjs", "test/webui-browser/transcript.spec.mjs", "test/webui-browser/turn-lifecycle.spec.mjs", "test/webui-browser/workspace-progress-live.spec.mjs", diff --git a/scripts/lib/webui-boundary.d.mts b/scripts/lib/webui-boundary.d.mts new file mode 100644 index 000000000..1c895c0a6 --- /dev/null +++ b/scripts/lib/webui-boundary.d.mts @@ -0,0 +1,102 @@ +// Types for `webui-boundary.mjs`, the pure WebUI build-boundary rules. +// The module is plain JavaScript, so its rule shapes live here instead of +// being inherited as `any` at every TypeScript call site — the boundary test +// (`packages/webui/test/unit/webui-boundary-check.test.ts`) drives all six +// rules through this declaration, and `scripts/check-webui-boundary.mjs` runs +// the same `rules` array against the built metafile. +// +// Every rule is pure with respect to the graph: they read the *keys* of the +// metafile `inputs` map, and only `forbiddenInternalReferences` reads anything +// else (the text of each named file, off disk). The per-input value is +// therefore declared structurally rather than modelled field-for-field, so a +// fixture built from just the `bytesInOutput` the rules never look at is still +// a legitimate input. + +/** One `imports` entry of an esbuild metafile input. */ +export interface MetafileImport { + readonly path: string; + readonly kind: string; + readonly external?: boolean; +} + +/** + * The value side of one metafile `inputs` entry. Optional throughout: the + * boundary rules never read a field, and the metafile emitter omits + * `imports` for leaf inputs. + */ +export interface MetafileInput { + readonly bytesInOutput?: number; + readonly imports?: readonly MetafileImport[]; +} + +/** + * An esbuild metafile `inputs` map keyed by build input path. Keys arrive in + * the forms `rewriteInputs` normalises, so callers hand over the raw map. + */ +export type MetafileInputMap = Record; + +/** The outcome of a single boundary rule. */ +export interface BoundaryResult { + readonly pass: boolean; + readonly violations: readonly string[]; +} + +/** Reader options shared by the rules that touch the filesystem. */ +export interface BoundaryRuleOptions { + /** Repository root the normalised keys are relative to. */ + readonly rootDir?: string; +} + +/** + * Element type of {@link rules}. Both callers — the CLI and the test — invoke + * every rule uniformly as `rule(inputs, packageExports)`, so the second + * argument is deliberately untyped here: `onlyAllowedPublicEntries` reads the + * allowlist out of it, `forbiddenInternalReferences` reads `rootDir` off the + * same slot, and the remaining rules ignore it. + */ +export interface BoundaryRule { + (inputs: MetafileInputMap, argument?: unknown): BoundaryResult; +} + +/** Matches any input that resolved into the terminal renderer. */ +export const TERMINAL_RENDERER_RE: RegExp; + +/** + * Normalises raw metafile input keys (pnpm-symlink form, `../` escapes and + * in-repository absolute paths) to the repository-relative form the rules + * match on. + * + * The return type is a generic passthrough so the caller's own key set + * survives the rewrite: a widened `MetafileInputMap` return would erase the + * literal keys that callers read back with `Object.keys`. + */ +export function rewriteInputs( + rawInputs: T, + options?: BoundaryRuleOptions, +): T; + +export function serverAndClientEntriesPresent( + inputs: MetafileInputMap, +): BoundaryResult; + +export function retiredSourcesAbsent(inputs: MetafileInputMap): BoundaryResult; + +export function terminalRendererAbsent(inputs: MetafileInputMap): BoundaryResult; + +export function forbiddenInternalReferences( + inputs: MetafileInputMap, + options?: BoundaryRuleOptions, +): BoundaryResult; + +export function noServerCallIntoCli(inputs: MetafileInputMap): BoundaryResult; + +export function onlyAllowedPublicEntries( + inputs: MetafileInputMap, + packageExports?: ReadonlySet, +): BoundaryResult; + +/** All six rules, in the order the CLI applies them. */ +export const rules: readonly BoundaryRule[]; + +/** Reader options the CLI entry point passes through unchanged. */ +export const defaultOptions: Readonly<{ rootDir: string }>; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 3c1ad6beb..66a604688 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -55,6 +55,10 @@ const steps = [ { name: "build", script: "build", windows: true }, { name: "check:standalone", script: "check:standalone", windows: true }, { name: "typecheck:webui", script: "typecheck:webui" }, + // Compiler inputs are identical across the matrix, and this program is a + // second compile of the same WebUI sources as `typecheck:webui` above. One + // Linux job runs it; the three per-program checks stay on every platform. + { name: "typecheck:webui-full", script: "typecheck:webui-full", fullOnly: true }, { name: "build:webui", script: "build:webui" }, { name: "check:webui-boundary", script: "check:webui-boundary" }, { name: "test:webui", script: "test:webui" }, From e2fc2c8df5d46036ee933149796b7231694d3338 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 21:52:52 +0800 Subject: [PATCH 5/5] test(verify): derive the platform profile's full-only gates instead of naming one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check asserted that the `platform` profile is the `full` list minus exactly one gate, `typecheck`, which was true when there was one. Adding a second `fullOnly` gate — `typecheck:webui-full`, the compile that actually covers the 67 test files nothing used to typecheck — made the expectation false and took all three platforms red. The gate names are now derived by diffing the two profiles' own `--list` output, so a third `fullOnly` gate needs no edit here. The assertion that `typecheck` is among them stays, so the test still fails if the compiler gates stop being platform-exempt. --- test/source-sync.test.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index b5dd452f3..5008c034e 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -664,15 +664,21 @@ function verificationFixture(t) { }; } -test('platform verification omits only the compiler gate and invalid profiles fail closed', t => { +test('platform verification omits only the compiler gates and invalid profiles fail closed', t => { const f = verificationFixture(t); const full = f.run(['--list']); const platform = f.run(['--profile', 'platform', '--list']); assert.equal(full.status, 0, full.stderr); assert.equal(platform.status, 0, platform.stderr); const gates = full.stdout.trim().split('\n'); - assert.ok(gates.includes('typecheck')); - assert.deepEqual(platform.stdout.trim().split('\n'), gates.filter(g => g !== 'typecheck')); + // Every gate the verifier marks `fullOnly` is one Linux job's work: the + // compiler inputs are identical across the matrix, so `platform` drops them. + // Derived from the verifier's own list rather than hardcoded, because + // adding a third `fullOnly` gate must not need a second edit here. + const fullOnly = gates.filter((g) => f.run(['--profile', 'full', '--list']).stdout.includes(g) + && !f.run(['--profile', 'platform', '--list']).stdout.split('\n').includes(g)); + assert.ok(fullOnly.includes('typecheck'), `expected typecheck to be full-only, got ${fullOnly.join(', ')}`); + assert.deepEqual(platform.stdout.trim().split('\n'), gates.filter((g) => !fullOnly.includes(g))); assert.notEqual(f.run(['--profile', 'platfrom', '--list']).status, 0); assert.notEqual(f.run(['--unknown']).status, 0); assert.equal(existsSync(f.reportDir), false);