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/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/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/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..66c250994 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,7 +225,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - 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( @@ -232,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, }); }); @@ -240,7 +252,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - 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( @@ -255,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, }); }); @@ -263,7 +278,9 @@ describe("W2 · stream subscription ownership · one stream per turn", () => { stream: { ...initialWebuiStreamState, phase: "streaming", - 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( @@ -286,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, }); }); @@ -437,7 +455,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 +503,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 +521,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 +936,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 +1119,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", 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/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 c8d7b2500..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", @@ -3748,7 +3749,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", @@ -3756,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", @@ -3791,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/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/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" }, 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); 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