diff --git a/AGENTS.md b/AGENTS.md index 69a0ae66b2..31a480e739 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ never seeds data on a client's behalf. init can then walk up to the enclosing `.git` and land a genesis commit on the working branch. Tests that boot the hub or touch seed/deploy git paths must call `installDisposableHubDataDir()` from - `test/disposable-hub-data-dir.ts`; never delete the `HUB_DATA_DIR` key. + `e2e/lib/disposable-hub-data-dir.ts`; never delete the `HUB_DATA_DIR` key. - Deployment mechanics are not settled enough to state here — see [IMPLEMENTATION.md](IMPLEMENTATION.md)'s Deployment section first. @@ -72,10 +72,16 @@ Env flags (unset behavior): | `HUB_DATA_DIR` | hub boot fails — required runtime config | | `CI` | set by GitHub Actions; not a caller flag | | `E2E_PROVIDER` / `E2E_PROVIDER_API_KEY` / `OLLAMA_BASE_URL` | live-inference e2e stays on the noop/stub path | -| `CHROME_PATH` | browser walkthrough falls back to platform defaults, fails if none exist | ## Tests +Two homes only. A load-bearing unit test sits beside the module it covers +(`src/**/*.test.ts`); a load-bearing integration or DB-backed suite lives +under root `e2e//`, with shared helpers in `e2e/lib/`. No `test/` +directory exists anywhere — not in apps, packages, agents, or tools. A test +that isn't load-bearing (pins a literal, a shape, a re-export, or re-proves +Interchange's own behavior) is deleted, not moved. + Tests are meaningful red/green tests only — no coverage theater. An outdated test is deleted in the same PR that breaks it, not adapted to keep passing; rebuilding coverage for the area it covered happens under CL-8150. diff --git a/agents/myra/package.json b/agents/myra/package.json index 4d817c83e5..fa1509fcca 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -10,6 +10,8 @@ }, "files": [ "src", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", "LICENSE", "README.md" ], diff --git a/agents/myra/test/boundary.test.ts b/agents/myra/src/boundary.test.ts similarity index 95% rename from agents/myra/test/boundary.test.ts rename to agents/myra/src/boundary.test.ts index 7cd3621b2f..cb7c5bcb48 100644 --- a/agents/myra/test/boundary.test.ts +++ b/agents/myra/src/boundary.test.ts @@ -35,7 +35,7 @@ test("shipped sources import only published platform packages", async () => { const importPattern = /from\s+"([^"]+)"/g; const violations: string[] = []; for (const file of await shippedFiles()) { - if (!file.endsWith(".ts")) continue; + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue; const content = await readFile(file, "utf8"); for (const match of content.matchAll(importPattern)) { const specifier = match[1] ?? ""; diff --git a/agents/myra/test/definition.test.ts b/agents/myra/src/definition.test.ts similarity index 99% rename from agents/myra/test/definition.test.ts rename to agents/myra/src/definition.test.ts index ffb44ec86d..164d72aa68 100644 --- a/agents/myra/test/definition.test.ts +++ b/agents/myra/src/definition.test.ts @@ -12,7 +12,7 @@ import { ASSISTANT_WORKFLOW_ID, buildAssistantWorkflow, serializeAssistantWorkflow, -} from "../src/index"; +} from "./index"; const INPUT = { triggerAddress: "ins_dep000000000000@example.test", diff --git a/agents/myra/tsconfig.json b/agents/myra/tsconfig.json index a8c2b9966d..e125e90614 100644 --- a/agents/myra/tsconfig.json +++ b/agents/myra/tsconfig.json @@ -10,6 +10,6 @@ "tsBuildInfoFile": "tsconfig.tsbuildinfo", "rootDir": "../.." }, - "include": ["src", "test"], + "include": ["src"], "exclude": [] } diff --git a/apps/hub/tsconfig.json b/apps/hub/tsconfig.json index cd2c17eb40..db14f93e63 100644 --- a/apps/hub/tsconfig.json +++ b/apps/hub/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["src", "test"], + "include": ["src"], "compilerOptions": { "types": ["bun"], "customConditions": ["bun"], diff --git a/apps/web/bunfig.toml b/apps/web/bunfig.toml index 564f98dac7..3471b992ac 100644 --- a/apps/web/bunfig.toml +++ b/apps/web/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./test/dom-environment.ts"] +preload = ["./src/dom-environment.ts"] diff --git a/apps/web/package.json b/apps/web/package.json index 25b09d158d..a8ca2c97e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "typecheck": "tsc --noEmit", "build": "vite build", "watch": "vite build --watch", - "test": "bun run build && bun test ./src ./test" + "test": "bun run build && bun test ./src" }, "dependencies": { "@corbits/agent-directory": "workspace:*", diff --git a/apps/web/test/action-command-dispatch-race.test.tsx b/apps/web/src/action-command-dispatch-race.test.tsx similarity index 95% rename from apps/web/test/action-command-dispatch-race.test.tsx rename to apps/web/src/action-command-dispatch-race.test.tsx index 13f8f4a93b..55a7f45d70 100644 --- a/apps/web/test/action-command-dispatch-race.test.tsx +++ b/apps/web/src/action-command-dispatch-race.test.tsx @@ -14,8 +14,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { resetPendingDialogRequests, runActionCommand } from "../src/command-palette-actions"; -import { SkillsPage } from "../src/pages/skills-page"; +import { resetPendingDialogRequests, runActionCommand } from "./command-palette-actions"; +import { SkillsPage } from "./pages/skills-page"; import { TestQueryProvider } from "./test-query-provider"; let container: HTMLDivElement | null = null; diff --git a/apps/web/test/bench/membership.test.ts b/apps/web/src/bench/membership.test.ts similarity index 91% rename from apps/web/test/bench/membership.test.ts rename to apps/web/src/bench/membership.test.ts index 5c13b385cb..2e9dbb352e 100644 --- a/apps/web/test/bench/membership.test.ts +++ b/apps/web/src/bench/membership.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { isRawIdentifier } from "../../src/bench/membership"; +import { isRawIdentifier } from "./membership"; describe("isRawIdentifier", () => { test("recognizes every platform id prefix this surface must never render", () => { diff --git a/apps/web/test/chat/agent-display-names.test.ts b/apps/web/src/chat/agent-display-names.test.ts similarity index 92% rename from apps/web/test/chat/agent-display-names.test.ts rename to apps/web/src/chat/agent-display-names.test.ts index ccffac90ed..f9bb8c5bca 100644 --- a/apps/web/test/chat/agent-display-names.test.ts +++ b/apps/web/src/chat/agent-display-names.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - agentDisplayNamesFromAgents, - displayNameForAddress, -} from "../../src/chat/agent-display-names"; +import { agentDisplayNamesFromAgents, displayNameForAddress } from "./agent-display-names"; describe("agentDisplayNamesFromAgents", () => { test("keys each agent's display name by its participant address", () => { diff --git a/apps/web/test/chat/approve-block.test.tsx b/apps/web/src/chat/blocks/approve-block.test.tsx similarity index 98% rename from apps/web/test/chat/approve-block.test.tsx rename to apps/web/src/chat/blocks/approve-block.test.tsx index 270152404a..d8f54d3b32 100644 --- a/apps/web/test/chat/approve-block.test.tsx +++ b/apps/web/src/chat/blocks/approve-block.test.tsx @@ -18,9 +18,9 @@ import type { ApprovalLiveStatus, ApprovalStatusQuery, PlatformApprovalDetail, -} from "../../src/chat/blocks/approval-actions"; -import type { MessageItem } from "../../src/chat/api"; -import { WorkbenchTimeline } from "../../src/chat/timeline"; +} from "./approval-actions"; +import type { MessageItem } from "../api"; +import { WorkbenchTimeline } from "../timeline"; const PLATFORM_DETAIL: PlatformApprovalDetail = { agentName: "Payments Bot", diff --git a/apps/web/test/chat/composer-synchronous-double-send.test.tsx b/apps/web/src/chat/composer-synchronous-double-send.test.tsx similarity index 95% rename from apps/web/test/chat/composer-synchronous-double-send.test.tsx rename to apps/web/src/chat/composer-synchronous-double-send.test.tsx index 0e36126cd6..9355d33f47 100644 --- a/apps/web/test/chat/composer-synchronous-double-send.test.tsx +++ b/apps/web/src/chat/composer-synchronous-double-send.test.tsx @@ -10,8 +10,8 @@ import { act, createElement, createRef } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import { Composer } from "../../src/chat/composer"; -import type { ComposerHandle, ComposerSendPayload } from "../../src/chat/composer"; +import { Composer } from "./composer"; +import type { ComposerHandle, ComposerSendPayload } from "./composer"; let container: HTMLDivElement | null = null; let root: Root | null = null; diff --git a/apps/web/src/chat/composer.test.tsx b/apps/web/src/chat/composer.test.tsx index 488b3735a5..5a487308f2 100644 --- a/apps/web/src/chat/composer.test.tsx +++ b/apps/web/src/chat/composer.test.tsx @@ -1,119 +1,848 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { act, createElement, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import * as errorSink from "@corbits/error-sink"; -import { - canStopComposer, - insertTextAtCaret, - spliceDictationTranscript, - speechRecognitionConstructor, - transcriptFromSpeechResults, -} from "./composer"; -import { isAwaitingReply } from "./streaming-reply"; +import { Composer } from "./composer"; +import type { ComposerHandle, ComposerSendPayload } from "./composer"; -describe("insertTextAtCaret", () => { - test("splices the insertion in at the caret", () => { - const result = insertTextAtCaret("hello world", 6, "@myra "); - expect(result.text).toBe("hello @myra world"); - expect(result.caret).toBe(12); +// The in-flight send contract survives the icon-only UI: its accessible name +// announces progress and the action remains unavailable until sending ends. +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) { + act(() => root?.unmount()); + root = null; + } + if (container !== null) { + container.remove(); + container = null; + } +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +function mount(onSend: () => Promise) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const ref = createRef(); + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [], + onSend, + }), + ); }); + return container; +} + +function sendButton(): HTMLButtonElement { + const button = container?.querySelector('[aria-label^="Send"]'); + if (button === null || button === undefined) { + throw new Error("send button not found"); + } + return button; +} + +function keyboardHint(): Element | null { + return container?.querySelector(".chat-composer-keyboard-hint") ?? null; +} + +describe("Composer send button", () => { + test("updates its accessible label and stays disabled while sending", async () => { + let resolveSend: (value: boolean) => void = () => undefined; + const onSend = () => + new Promise((resolve) => { + resolveSend = resolve; + }); + mount(onSend); + + const textarea = container?.querySelector("textarea"); + if (textarea === null || textarea === undefined) { + throw new Error("composer textarea not found"); + } + act(() => { + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(textarea, "hello there"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + await settle(); + + expect(sendButton().getAttribute("aria-label")).toBe("Send"); - test("appends at the end when the caret sits past the text", () => { - const result = insertTextAtCaret("hi", 2, "@myra "); - expect(result.text).toBe("hi@myra "); - expect(result.caret).toBe(8); + act(() => { + sendButton().click(); + }); + await settle(); + + expect(sendButton().getAttribute("aria-label")).toBe("Sending…"); + expect(sendButton().hasAttribute("disabled")).toBe(true); + + await act(async () => { + resolveSend(true); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(sendButton().getAttribute("aria-label")).toBe("Send"); }); +}); - test("inserts into an empty draft", () => { - const result = insertTextAtCaret("", 0, "@myra "); - expect(result.text).toBe("@myra "); - expect(result.caret).toBe(6); +test("labels the icon-only attachment action in the composer rail", () => { + mount(() => Promise.resolve(true)); + + expect(container?.querySelector('[aria-label="Attach files"]')?.textContent).toBe(""); +}); + +function textarea(): HTMLTextAreaElement { + const element = container?.querySelector("textarea"); + if (element === null || element === undefined) { + throw new Error("composer textarea not found"); + } + return element; +} + +function typeInto(element: HTMLTextAreaElement, text: string) { + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(element, text); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function mountWithMentions(onSend: (payload: ComposerSendPayload) => Promise) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const ref = createRef(); + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [{ id: "researcher@agents.example", handle: "researcher", label: "Researcher" }], + onSend, + }), + ); + }); + return container; +} + +describe("Composer keyboard hint", () => { + test("uses the existing action rail and appears only for a focused non-empty draft", async () => { + mount(() => Promise.resolve(true)); + expect(keyboardHint()).not.toBeNull(); + expect(keyboardHint()?.getAttribute("data-visible")).toBe("false"); + expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("true"); + + act(() => { + textarea().focus(); + }); + typeInto(textarea(), "hello"); + await settle(); + + expect(keyboardHint()?.getAttribute("data-visible")).toBe("true"); + expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("false"); + expect(keyboardHint()?.textContent).toBe("Enter to send"); + expect(container?.querySelectorAll(".chat-composer-actions button").length).toBe(2); + expect(container?.querySelector(".chat-composer-row > textarea")).toBe(textarea()); + }); + + test("hides when the focused draft is cleared", async () => { + mount(() => Promise.resolve(true)); + act(() => { + textarea().focus(); + }); + typeInto(textarea(), "hello"); + await settle(); + expect(keyboardHint()?.getAttribute("data-visible")).toBe("true"); + + typeInto(textarea(), ""); + await settle(); + expect(keyboardHint()?.getAttribute("data-visible")).toBe("false"); + expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("true"); }); }); -describe("spliceDictationTranscript", () => { - test("inserts a space so two words do not glue together", () => { - const result = spliceDictationTranscript("hello", "", "world"); - expect(result.text).toBe("hello world"); - expect(result.caret).toBe(11); +describe("Composer growth containment", () => { + test("the textarea carries the max-height/overflow class and keeps applying its measured inline height", async () => { + mount(() => Promise.resolve(true)); + expect(textarea().className).toContain("chat-composer-input"); + + typeInto(textarea(), "line one\nline two\nline three"); + await settle(); + // The auto-grow effect still measures and writes an inline height on + // every change — the CSS transition only smooths that write, it does + // not replace it. + expect(textarea().style.height.endsWith("px")).toBe(true); }); +}); + +describe("Composer popover entrance", () => { + test("the mention popover carries the entrance class", async () => { + mountWithMentions(() => Promise.resolve(true)); + typeInto(textarea(), "@"); + await settle(); + const popover = container?.querySelector(".chat-mention-popover"); + expect(popover?.classList.contains("chat-popover-enter")).toBe(true); + }); +}); + +describe("Composer hit targets", () => { + test("attach and send buttons carry the extended-hit-area class", () => { + mount(() => Promise.resolve(true)); + const buttons = container?.querySelectorAll(".chat-composer-icon-button"); + expect(buttons?.length).toBe(2); + }); +}); + +describe("ComposerHandle.setText", () => { + test("replaces the existing draft and focuses with the caret at the end", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const ref = createRef(); + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [], + onSend: () => Promise.resolve(true), + }), + ); + }); + + typeInto(textarea(), "unsent draft"); + await settle(); + expect(textarea().value).toBe("unsent draft"); + + await act(async () => { + ref.current?.setText("previous prompt"); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); - test("does not add a second space when the prefix already ends with one", () => { - const result = spliceDictationTranscript("hello ", "", "world"); - expect(result.text).toBe("hello world"); - expect(result.caret).toBe(11); + expect(textarea().value).toBe("previous prompt"); + expect(document.activeElement).toBe(textarea()); + expect(textarea().selectionStart).toBe("previous prompt".length); + expect(textarea().selectionEnd).toBe("previous prompt".length); }); - test("keeps a following suffix on its own word boundary", () => { - const result = spliceDictationTranscript("hello", "there", "world"); - expect(result.text).toBe("hello world there"); - expect(result.caret).toBe(11); + test("Enter after setText of an @handle prompt sends instead of opening mention", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const ref = createRef(); + const sent: ComposerSendPayload[] = []; + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [{ id: "researcher@agents.example", handle: "researcher", label: "Researcher" }], + onSend: (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + }), + ); + }); + + await act(async () => { + ref.current?.setText("@researcher"); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); + await settle(); + + expect(container?.querySelector(".chat-mention-popover")).toBeNull(); + + act(() => { + textarea().dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + }); + await settle(); + + expect(sent).toEqual([{ text: "@researcher", attachments: [] }]); }); - test("drops an empty transcript without changing the draft", () => { - const result = spliceDictationTranscript("hello", " there", " "); - expect(result.text).toBe("hello there"); - expect(result.caret).toBe(5); + test("Send after setText does not carry leftover attachments", async () => { + const sent: ComposerSendPayload[] = []; + const ref = createRef(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [], + onSend: (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + }), + ); + }); + + const fileInput = container.querySelector(".chat-composer-file-input"); + if (fileInput === null) throw new Error("file input not found"); + + const file = new File(["hello"], "notes.txt", { type: "text/plain" }); + Object.defineProperty(fileInput, "files", { + configurable: true, + value: { + 0: file, + length: 1, + item: (index: number) => (index === 0 ? file : null), + [Symbol.iterator]: function* () { + yield file; + }, + }, + }); + act(() => { + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + await settle(); + await settle(); + expect(container.querySelector(".chat-composer-attachments")).not.toBeNull(); + + await act(async () => { + ref.current?.setText("copied prompt"); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); + await settle(); + + expect(container.querySelector(".chat-composer-attachments")).toBeNull(); + + act(() => { + sendButton().click(); + }); + await settle(); + + expect(sent).toEqual([{ text: "copied prompt", attachments: [] }]); }); }); -describe("transcriptFromSpeechResults", () => { - test("concatenates every alternative in order", () => { - expect( - transcriptFromSpeechResults([ - { isFinal: true, length: 1, 0: { transcript: "hello " } }, - { isFinal: false, length: 1, 0: { transcript: "world" } }, - ]), - ).toBe("hello world"); +// The composer's Stop affordance. `handleStop` guards against a +// double-click and re-enables itself once the host reports the turn is +// no longer running -- but a REJECTED stop request must also re-enable +// it, or a genuinely failed cancel (not a slow one) leaves the person +// with a permanently disabled button and no way to retry for the rest +// of that turn's life. +function stopButton(): HTMLButtonElement { + const button = container?.querySelector('[aria-label="Stop"]'); + if (button === null || button === undefined) { + throw new Error("stop button not found"); + } + return button; +} + +function mountStoppable( + running: boolean, + onStop: () => void | Promise, + onSend: (payload: ComposerSendPayload) => Promise = () => Promise.resolve(true), +) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + onSend, + running, + onStop, + }), + ); + }); + return { + container, + rerender: (nextRunning: boolean) => { + act(() => { + root?.render( + createElement(Composer, { + agents: [], + onSend, + running: nextRunning, + onStop, + }), + ); + }); + }, + }; +} + +describe("Composer stop affordance", () => { + test("keeps Stop and Send together in the right-aligned action group", () => { + mountStoppable(true, () => undefined); + + const actions = container?.querySelector(".chat-composer-actions"); + const submitActions = stopButton().parentElement; + expect(actions?.children).toHaveLength(3); + expect(submitActions?.classList.contains("chat-composer-submit-actions")).toBe(true); + expect(submitActions?.children).toHaveLength(2); + expect(submitActions?.lastElementChild).toBe(sendButton()); + expect(actions?.lastElementChild).toBe(submitActions); + }); + + test("gives Stop and Send distinct accessible names while both are visible", () => { + mountStoppable(true, () => undefined); + + expect(stopButton().getAttribute("aria-label")).toBe("Stop"); + expect(sendButton().getAttribute("aria-label")).toBe("Send"); + }); + + test("keeps queued sends available while Stop is visible", async () => { + const sent: ComposerSendPayload[] = []; + mountStoppable( + true, + () => undefined, + (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + ); + + typeInto(textarea(), "follow-up"); + await settle(); + + expect(sendButton().hasAttribute("disabled")).toBe(false); + act(() => { + sendButton().click(); + }); + await settle(); + + expect(sent).toEqual([{ text: "follow-up", attachments: [] }]); + }); + + test("renders no Stop button when no turn is running", () => { + mountStoppable(false, () => undefined); + expect(container?.querySelector('[aria-label="Stop"]')).toBeNull(); + }); + + test("clicking Stop calls onStop and disables the button against a double-click", async () => { + let calls = 0; + mountStoppable(true, () => { + calls += 1; + }); + + expect(stopButton().hasAttribute("disabled")).toBe(false); + act(() => { + stopButton().click(); + }); + await settle(); + + expect(calls).toBe(1); + expect(stopButton().hasAttribute("disabled")).toBe(true); + + act(() => { + stopButton().click(); + }); + await settle(); + expect(calls).toBe(1); + }); + + test("the button re-enables once the host reports the turn is no longer running", async () => { + const { rerender } = mountStoppable(true, () => undefined); + act(() => { + stopButton().click(); + }); + await settle(); + expect(stopButton().hasAttribute("disabled")).toBe(true); + + rerender(false); + expect(container?.querySelector('[aria-label="Stop"]')).toBeNull(); + }); + + test("a stop request that REJECTS re-enables the button instead of leaving it stuck", async () => { + let reject: (err: unknown) => void = () => undefined; + mountStoppable( + true, + () => + new Promise((_resolve, rej) => { + reject = rej; + }), + ); + + act(() => { + stopButton().click(); + }); + await settle(); + expect(stopButton().hasAttribute("disabled")).toBe(true); + + await act(async () => { + reject(new Error("network error")); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The turn is still running (the cancel request itself failed, not + // the turn) -- the button must come back so the user can try again. + expect(stopButton().hasAttribute("disabled")).toBe(false); }); }); -describe("speechRecognitionConstructor", () => { - test("returns null when the browser has no speech recognition", () => { - expect(speechRecognitionConstructor({})).toBeNull(); +describe("Composer dictate", () => { + const recognitions: FakeSpeechRecognition[] = []; + + class FakeSpeechRecognition { + continuous = false; + interimResults = false; + onresult: ((event: { results: FakeSpeechResult[] }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onend: (() => void) | null = null; + started = false; + resultOnStop: readonly { isFinal: boolean; transcript: string }[] | null = null; + + constructor() { + recognitions.push(this); + } + + start() { + this.started = true; + } + + stop() { + this.started = false; + if (this.resultOnStop !== null) { + this.emit(this.resultOnStop); + this.resultOnStop = null; + } + this.onend?.(); + } + + abort() { + this.started = false; + this.onend?.(); + } + + emit(segments: readonly { isFinal: boolean; transcript: string }[]) { + const results = segments.map((segment) => ({ + isFinal: segment.isFinal, + length: 1, + 0: { transcript: segment.transcript }, + })); + this.onresult?.({ results }); + } + } + + type FakeSpeechResult = { + readonly isFinal: boolean; + readonly length: number; + readonly 0: { readonly transcript: string }; + }; + + function installSpeechRecognition() { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + writable: true, + value: FakeSpeechRecognition, + }); + } + + afterEach(() => { + recognitions.length = 0; + Reflect.deleteProperty(window, "SpeechRecognition"); }); - test("prefers SpeechRecognition over the webkit prefix", () => { - function SpeechRecognition() { - return undefined; + test("hides the dictate control when speech recognition is unavailable", () => { + mount(() => Promise.resolve(true)); + expect(container?.querySelector('[aria-label="Dictate"]')).toBeNull(); + }); + + test("starts recognition, inserts a transcript on a word boundary, and stops", async () => { + installSpeechRecognition(); + mount(() => Promise.resolve(true)); + typeInto(textarea(), "hello"); + textarea().setSelectionRange(5, 5); + await settle(); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); } - function webkitSpeechRecognition() { - return undefined; + + act(() => { + dictate.click(); + }); + await settle(); + + expect(recognitions.at(-1)?.started).toBe(true); + expect(recognitions.at(-1)?.continuous).toBe(true); + expect(recognitions.at(-1)?.interimResults).toBe(true); + expect(dictate.getAttribute("aria-label")).toBe("Stop dictating"); + expect(dictate.getAttribute("aria-pressed")).toBe("true"); + expect(dictate.getAttribute("data-listening")).toBe("true"); + + act(() => { + recognitions.at(-1)?.emit([{ isFinal: true, transcript: "world" }]); + }); + await settle(); + + expect(textarea().value).toBe("hello world"); + + act(() => { + dictate.click(); + }); + await settle(); + + expect(recognitions.at(-1)?.started).toBe(false); + const idle = container?.querySelector('[aria-label="Dictate"]'); + expect(idle?.getAttribute("aria-pressed")).toBe("false"); + expect(idle?.getAttribute("data-listening")).toBe("false"); + }); + + test("user Stop flushes a late final result into the draft", async () => { + installSpeechRecognition(); + mount(() => Promise.resolve(true)); + typeInto(textarea(), "hello"); + textarea().setSelectionRange(5, 5); + await settle(); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); } - const found = speechRecognitionConstructor({ - SpeechRecognition, - webkitSpeechRecognition, + + act(() => { + dictate.click(); + }); + await settle(); + + const rec = recognitions.at(-1); + if (rec === undefined) { + throw new Error("speech recognition was not constructed"); + } + rec.resultOnStop = [{ isFinal: true, transcript: "world" }]; + + act(() => { + dictate.click(); }); - expect(Object.is(found, SpeechRecognition)).toBe(true); + await settle(); + + expect(textarea().value).toBe("hello world"); + expect(rec.started).toBe(false); }); - test("falls back to webkitSpeechRecognition", () => { - function webkitSpeechRecognition() { - return undefined; + test("send while listening does not restore the draft from a late final result", async () => { + installSpeechRecognition(); + const sent: ComposerSendPayload[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + onSend: (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + }), + ); + }); + typeInto(textarea(), "hello"); + textarea().setSelectionRange(5, 5); + await settle(); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); + } + + act(() => { + dictate.click(); + }); + await settle(); + + const rec = recognitions.at(-1); + if (rec === undefined) { + throw new Error("speech recognition was not constructed"); } - const found = speechRecognitionConstructor({ webkitSpeechRecognition }); - expect(Object.is(found, webkitSpeechRecognition)).toBe(true); + rec.resultOnStop = [{ isFinal: true, transcript: "late words" }]; + + act(() => { + sendButton().click(); + }); + await settle(); + + act(() => { + rec.emit([{ isFinal: true, transcript: "late words" }]); + }); + await settle(); + + expect(textarea().value).toBe(""); + expect(sent).toEqual([{ text: "hello", attachments: [] }]); }); -}); -// The composer's stop affordance is a stand-in for "is there a -// turn to cancel" — offered whenever the host says a turn is running, -// independent of the composer's own `sending`/`preparing` state (queuing -// a follow-up message while a turn runs is still allowed, so the stop -// affordance and the send button coexist rather than one gating the -// other). -describe("canStopComposer", () => { - test("offers Stop while the host reports a turn running", () => { - expect(canStopComposer({ running: true })).toBe(true); + test("Enter while listening does not restore the draft from a late final result", async () => { + installSpeechRecognition(); + const sent: ComposerSendPayload[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + onSend: (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + }), + ); + }); + typeInto(textarea(), "hello"); + textarea().setSelectionRange(5, 5); + await settle(); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); + } + + act(() => { + dictate.click(); + }); + await settle(); + + const rec = recognitions.at(-1); + if (rec === undefined) { + throw new Error("speech recognition was not constructed"); + } + rec.resultOnStop = [{ isFinal: true, transcript: "late words" }]; + + act(() => { + textarea().dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + }); + await settle(); + + act(() => { + rec.emit([{ isFinal: true, transcript: "late words" }]); + }); + await settle(); + + expect(textarea().value).toBe(""); + expect(sent).toEqual([{ text: "hello", attachments: [] }]); }); - test("offers nothing when no turn is running", () => { - expect(canStopComposer({ running: false })).toBe(false); + test("stop then send does not restore the draft from a deferred final result", async () => { + installSpeechRecognition(); + const sent: ComposerSendPayload[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + onSend: (payload) => { + sent.push(payload); + return Promise.resolve(true); + }, + }), + ); + }); + typeInto(textarea(), "hello"); + textarea().setSelectionRange(5, 5); + await settle(); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); + } + + act(() => { + dictate.click(); + }); + await settle(); + + const rec = recognitions.at(-1); + if (rec === undefined) { + throw new Error("speech recognition was not constructed"); + } + + act(() => { + dictate.click(); + }); + await settle(); + + expect(textarea().value).toBe("hello"); + + act(() => { + sendButton().click(); + }); + await settle(); + + act(() => { + rec.emit([{ isFinal: true, transcript: "late words" }]); + }); + await settle(); + + expect(textarea().value).toBe(""); + expect(sent).toEqual([{ text: "hello", attachments: [] }]); }); - test("offers Stop while awaiting a turn that already has streamed text", () => { - expect( - canStopComposer({ - running: isAwaitingReply({ phase: "awaiting", text: "Hello" }), - }), - ).toBe(true); + test("does not report aborted or no-speech recognition errors", async () => { + installSpeechRecognition(); + const report = spyOn(errorSink, "reportError"); + mount(() => Promise.resolve(true)); + + const dictate = container?.querySelector('[aria-label="Dictate"]'); + if (dictate === null || dictate === undefined) { + throw new Error("dictate button not found"); + } + + act(() => { + dictate.click(); + }); + await settle(); + + const rec = recognitions.at(-1); + if (rec === undefined) { + throw new Error("speech recognition was not constructed"); + } + + act(() => { + rec.onerror?.({ error: "no-speech" }); + rec.onerror?.({ error: "aborted" }); + }); + await settle(); + + expect(report).not.toHaveBeenCalled(); + + act(() => { + rec.onerror?.({ error: "network" }); + }); + await settle(); + + expect(report).toHaveBeenCalled(); + report.mockRestore(); }); }); diff --git a/apps/web/test/chat/mentions.test.ts b/apps/web/src/chat/mentions.test.ts similarity index 99% rename from apps/web/test/chat/mentions.test.ts rename to apps/web/src/chat/mentions.test.ts index 11ae93ba57..dd89358a09 100644 --- a/apps/web/test/chat/mentions.test.ts +++ b/apps/web/src/chat/mentions.test.ts @@ -5,7 +5,7 @@ import { filterMentionCandidates, insertMention, mentionCandidatesFromParticipants, -} from "../../src/chat/mentions"; +} from "./mentions"; describe("activeMentionQuery", () => { test("detects an open mention right after the @", () => { diff --git a/apps/web/test/chat/pending-send-lifecycle.test.tsx b/apps/web/src/chat/pending-send-lifecycle.test.tsx similarity index 96% rename from apps/web/test/chat/pending-send-lifecycle.test.tsx rename to apps/web/src/chat/pending-send-lifecycle.test.tsx index 35cbe8dafb..42410b6bd5 100644 --- a/apps/web/test/chat/pending-send-lifecycle.test.tsx +++ b/apps/web/src/chat/pending-send-lifecycle.test.tsx @@ -7,8 +7,8 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import type { PendingActions, TimelineMessageItem } from "../../src/chat/timeline"; -import { WorkbenchTimeline } from "../../src/chat/timeline"; +import type { PendingActions, TimelineMessageItem } from "./timeline"; +import { WorkbenchTimeline } from "./timeline"; let container: HTMLDivElement | null = null; let root: Root | null = null; diff --git a/apps/web/test/chat/refresh-and-send.test.ts b/apps/web/src/chat/refresh-and-send.test.ts similarity index 98% rename from apps/web/test/chat/refresh-and-send.test.ts rename to apps/web/src/chat/refresh-and-send.test.ts index fa02b1b76c..418b923b79 100644 --- a/apps/web/test/chat/refresh-and-send.test.ts +++ b/apps/web/src/chat/refresh-and-send.test.ts @@ -12,8 +12,8 @@ import { mergePendingSends, mergeStreamingReply, withScrollSnapshot, -} from "../../src/chat/chat-workspace"; -import type { PendingSend } from "../../src/chat/chat-workspace"; +} from "./chat-workspace"; +import type { PendingSend } from "./chat-workspace"; import { attachmentsAfterSend, attachmentBytesOnComposer, @@ -27,10 +27,10 @@ import { draftAfterSend, partsForSend, validateAttachmentPick, -} from "../../src/chat/composer"; -import type { ComposerAttachment } from "../../src/chat/composer"; -import { CHAT_STRINGS } from "../../src/chat/strings"; -import { backoffDelayMs, shouldConnect } from "../../src/chat/use-workbench-stream"; +} from "./composer"; +import type { ComposerAttachment } from "./composer"; +import { CHAT_STRINGS } from "./strings"; +import { backoffDelayMs, shouldConnect } from "./use-workbench-stream"; describe("draftAfterSend (B2: a failed send keeps the draft)", () => { test("clears the draft once the send succeeds", () => { diff --git a/apps/web/test/chat/single-message-list-reconciliation.test.tsx b/apps/web/src/chat/single-message-list-reconciliation.test.tsx similarity index 96% rename from apps/web/test/chat/single-message-list-reconciliation.test.tsx rename to apps/web/src/chat/single-message-list-reconciliation.test.tsx index ad746f03a4..23550b408c 100644 --- a/apps/web/test/chat/single-message-list-reconciliation.test.tsx +++ b/apps/web/src/chat/single-message-list-reconciliation.test.tsx @@ -11,9 +11,9 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import type { MessageItem } from "../../src/chat/api"; -import type { TimelineMessageItem } from "../../src/chat/timeline"; -import { WorkbenchTimeline } from "../../src/chat/timeline"; +import type { MessageItem } from "./api"; +import type { TimelineMessageItem } from "./timeline"; +import { WorkbenchTimeline } from "./timeline"; let container: HTMLDivElement | null = null; let root: Root | null = null; diff --git a/apps/web/test/chat/stream-apply.test.ts b/apps/web/src/chat/stream-apply.test.ts similarity index 93% rename from apps/web/test/chat/stream-apply.test.ts rename to apps/web/src/chat/stream-apply.test.ts index 6b5998633b..9bf1391a6e 100644 --- a/apps/web/test/chat/stream-apply.test.ts +++ b/apps/web/src/chat/stream-apply.test.ts @@ -4,8 +4,8 @@ import { describe, expect, test } from "bun:test"; -import { ensureReplyThreadRow } from "../../src/chat/use-workbench-feed"; -import type { WorkbenchThreadRow } from "../../src/chat/api"; +import { ensureReplyThreadRow } from "./use-workbench-feed"; +import type { WorkbenchThreadRow } from "./api"; describe("ensureReplyThreadRow", () => { const empty: { diff --git a/apps/web/test/chat/tool-activity-view.test.tsx b/apps/web/src/chat/tool-activity-view.test.tsx similarity index 98% rename from apps/web/test/chat/tool-activity-view.test.tsx rename to apps/web/src/chat/tool-activity-view.test.tsx index ceb345dcff..947ba8d1e7 100644 --- a/apps/web/test/chat/tool-activity-view.test.tsx +++ b/apps/web/src/chat/tool-activity-view.test.tsx @@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import type { ToolTracePart } from "../../src/chat/wire/parts"; -import { toToolActivityRow, type ToolActivityRow } from "../../src/chat/tool-activity"; -import { LiveToolActivity, ToolActivityGroup } from "../../src/chat/tool-activity-view"; +import type { ToolTracePart } from "./wire/parts"; +import { toToolActivityRow, type ToolActivityRow } from "./tool-activity"; +import { LiveToolActivity, ToolActivityGroup } from "./tool-activity-view"; function trace(part: Partial): ToolTracePart { return { diff --git a/apps/web/test/chat/use-workbench-stream.test.tsx b/apps/web/src/chat/use-workbench-stream.test.tsx similarity index 98% rename from apps/web/test/chat/use-workbench-stream.test.tsx rename to apps/web/src/chat/use-workbench-stream.test.tsx index 41fa0d5f2f..c35bf4cc97 100644 --- a/apps/web/test/chat/use-workbench-stream.test.tsx +++ b/apps/web/src/chat/use-workbench-stream.test.tsx @@ -11,7 +11,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; -import { parseStreamEventJson, useWorkbenchStream } from "../../src/chat/use-workbench-stream"; +import { parseStreamEventJson, useWorkbenchStream } from "./use-workbench-stream"; const realEventSource = globalThis.EventSource; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/apps/web/test/chat/blocks.test.tsx b/apps/web/src/chat/wire/blocks.test.tsx similarity index 97% rename from apps/web/test/chat/blocks.test.tsx rename to apps/web/src/chat/wire/blocks.test.tsx index d51ac88a74..acc9f6789c 100644 --- a/apps/web/test/chat/blocks.test.tsx +++ b/apps/web/src/chat/wire/blocks.test.tsx @@ -5,8 +5,8 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import type { MessageItem } from "../../src/chat/api"; -import { WorkbenchTimeline } from "../../src/chat/timeline"; +import type { MessageItem } from "../api"; +import { WorkbenchTimeline } from "../timeline"; function messageWithBlock(type: string, data: unknown): MessageItem[] { return [ diff --git a/apps/web/test/chat/workbench-presence.test.ts b/apps/web/src/chat/workbench-presence.test.ts similarity index 98% rename from apps/web/test/chat/workbench-presence.test.ts rename to apps/web/src/chat/workbench-presence.test.ts index 5164f19b61..c4469a197b 100644 --- a/apps/web/test/chat/workbench-presence.test.ts +++ b/apps/web/src/chat/workbench-presence.test.ts @@ -8,7 +8,7 @@ import { nextPresenceRoster, parsePresenceEvent, parsePresenceSnapshotEvent, -} from "../../src/chat/workbench-presence"; +} from "./workbench-presence"; describe("parsePresenceEvent", () => { test("parses a well-formed chat.presence payload", () => { diff --git a/apps/web/test/chat/workbenches-mutated-event.test.ts b/apps/web/src/chat/workbenches-mutated-event.test.ts similarity index 96% rename from apps/web/test/chat/workbenches-mutated-event.test.ts rename to apps/web/src/chat/workbenches-mutated-event.test.ts index d36c6e76ff..3f604d9f3d 100644 --- a/apps/web/test/chat/workbenches-mutated-event.test.ts +++ b/apps/web/src/chat/workbenches-mutated-event.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { - WORKBENCHES_MUTATED_EVENT, - applyStreamWorkbenchesMutated, - createWorkbench, -} from "../../src/chat/api"; +import { WORKBENCHES_MUTATED_EVENT, applyStreamWorkbenchesMutated, createWorkbench } from "./api"; if (typeof document === "undefined") { GlobalRegistrator.register(); diff --git a/apps/web/test/command-palette/entity-search.test.ts b/apps/web/src/command-palette/entity-search.test.ts similarity index 97% rename from apps/web/test/command-palette/entity-search.test.ts rename to apps/web/src/command-palette/entity-search.test.ts index d1927aa578..56ca447074 100644 --- a/apps/web/test/command-palette/entity-search.test.ts +++ b/apps/web/src/command-palette/entity-search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { searchEntities } from "../../src/command-palette/entity-search"; +import { searchEntities } from "./entity-search"; describe("searchEntities", () => { const sources = [ diff --git a/apps/web/test/command-palette/scope.test.ts b/apps/web/src/command-palette/scope.test.ts similarity index 96% rename from apps/web/test/command-palette/scope.test.ts rename to apps/web/src/command-palette/scope.test.ts index 3db2455b46..57eda6a26f 100644 --- a/apps/web/test/command-palette/scope.test.ts +++ b/apps/web/src/command-palette/scope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { isBareScopeQuery, parsePaletteQuery } from "../../src/command-palette/scope"; +import { isBareScopeQuery, parsePaletteQuery } from "./scope"; describe("parsePaletteQuery", () => { test("no prefix leaves the whole string unscoped", () => { diff --git a/apps/web/test/dom-environment.ts b/apps/web/src/dom-environment.ts similarity index 100% rename from apps/web/test/dom-environment.ts rename to apps/web/src/dom-environment.ts diff --git a/apps/web/test/fetch-dedupe.test.tsx b/apps/web/src/fetch-dedupe.test.tsx similarity index 97% rename from apps/web/test/fetch-dedupe.test.tsx rename to apps/web/src/fetch-dedupe.test.tsx index ee6fcebd92..3af04812e0 100644 --- a/apps/web/test/fetch-dedupe.test.tsx +++ b/apps/web/src/fetch-dedupe.test.tsx @@ -10,9 +10,9 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import { BenchProvider } from "../src/bench-context"; -import { requestWorkbenchRename } from "../src/workbench-rename-events"; -import { WorkbenchList } from "../src/shell/workbench-list"; +import { BenchProvider } from "./bench-context"; +import { requestWorkbenchRename } from "./workbench-rename-events"; +import { WorkbenchList } from "./shell/workbench-list"; import { createTestQueryClient, TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/library/artifacts.test.ts b/apps/web/src/library/artifacts.test.ts similarity index 98% rename from apps/web/test/library/artifacts.test.ts rename to apps/web/src/library/artifacts.test.ts index e1466d663c..9090bcc02d 100644 --- a/apps/web/test/library/artifacts.test.ts +++ b/apps/web/src/library/artifacts.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"; -import { ArtifactSummary, filterArtifacts, sortArtifacts } from "../../src/library/index"; +import { ArtifactSummary, filterArtifacts, sortArtifacts } from "./index"; function artifact(overrides: Partial & { readonly id: string }): ArtifactSummary { return { diff --git a/apps/web/test/login-routing.test.tsx b/apps/web/src/login-routing.test.tsx similarity index 97% rename from apps/web/test/login-routing.test.tsx rename to apps/web/src/login-routing.test.tsx index 7e0ecdb2d0..4b187a28d4 100644 --- a/apps/web/test/login-routing.test.tsx +++ b/apps/web/src/login-routing.test.tsx @@ -9,9 +9,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { act, useCallback, useEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { App } from "../src/app"; -import { validatedNextPath } from "../src/login-next"; -import type { SessionState, SessionUser } from "../src/session"; +import { App } from "./app"; +import { validatedNextPath } from "./login-next"; +import type { SessionState, SessionUser } from "./session"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/pending-approvals.test.tsx b/apps/web/src/pending-approvals.test.tsx similarity index 95% rename from apps/web/test/pending-approvals.test.tsx rename to apps/web/src/pending-approvals.test.tsx index 21864045d7..8dbb1e0279 100644 --- a/apps/web/test/pending-approvals.test.tsx +++ b/apps/web/src/pending-approvals.test.tsx @@ -10,11 +10,11 @@ import { QueryClient } from "@tanstack/react-query"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { createChatApprovalActions } from "../src/approval-actions"; -import { BenchContext, type BenchState } from "../src/bench-context"; -import { NavigationProvider } from "../src/navigation"; -import { MissionControlRoute } from "../src/pages/mission-control-page"; -import { usePendingApprovals } from "../src/pending-approvals"; +import { createChatApprovalActions } from "./approval-actions"; +import { BenchContext, type BenchState } from "./bench-context"; +import { NavigationProvider } from "./navigation"; +import { MissionControlRoute } from "./pages/mission-control-page"; +import { usePendingApprovals } from "./pending-approvals"; import { TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; diff --git a/apps/web/src/query-client.test.ts b/apps/web/src/query-client.test.ts index d8c8bb4a02..b4bc6d32b0 100644 --- a/apps/web/src/query-client.test.ts +++ b/apps/web/src/query-client.test.ts @@ -1,173 +1,41 @@ -import { describe, expect, mock, test } from "bun:test"; -import { QueryClient } from "@tanstack/react-query"; +// Query-key helpers — pure unit coverage so the TanStack cutover does not +// depend only on page-level smoke. The APIQuery adapter itself +// (`toAPIQuery`) is covered in `@/lib/api-query`. -import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; +import { describe, expect, test } from "bun:test"; -import { - createAppQueryClient, - isAuthInvalidError, - pathToQueryKey, - shouldRetryQuery, - tenantKeys, -} from "./query-client"; +import { meKeys, pathToQueryKey, tenantKeys } from "./query-client"; -const TENANT = "tenant_1"; - -describe("shouldRetryQuery", () => { - test("never retries once unauthenticated", () => { - expect(shouldRetryQuery(0, new UnauthenticatedError())).toBe(false); - }); - - test("never retries a definitive 404", () => { - expect(shouldRetryQuery(0, new ApiQueryError("not found", 404))).toBe(false); - }); - - test("retries other statuses up to 3 attempts", () => { - const error = new ApiQueryError("server exploded", 500); - expect(shouldRetryQuery(0, error)).toBe(true); - expect(shouldRetryQuery(2, error)).toBe(true); - expect(shouldRetryQuery(3, error)).toBe(false); - }); - - test("retries a network failure with no status up to 3 attempts", () => { - const error = new ApiQueryError("network down"); - expect(shouldRetryQuery(0, error)).toBe(true); - expect(shouldRetryQuery(3, error)).toBe(false); - }); -}); - -describe("isAuthInvalidError", () => { - test("an UnauthenticatedError is auth-invalid", () => { - expect(isAuthInvalidError(new UnauthenticatedError())).toBe(true); - }); - - test("an ApiQueryError with status 401 is auth-invalid", () => { - expect(isAuthInvalidError(new ApiQueryError("nope", 401))).toBe(true); - }); - - test("an ApiQueryError with a different status is not", () => { - expect(isAuthInvalidError(new ApiQueryError("nope", 403))).toBe(false); - expect(isAuthInvalidError(new ApiQueryError("boom", 500))).toBe(false); - }); - - test("a plain error is not", () => { - expect(isAuthInvalidError(new Error("boom"))).toBe(false); - }); -}); - -// a hub restarted on an empty DB mid-session (or a cookie for a -// deleted user, or a session that simply expired) shows up as a 401 on -// whatever query or mutation happens to run next — never a dedicated -// "session ended" event. Every consumer of this client must be routed -// through the same `onAuthInvalid` callback, not left to render its own -// local "sign in required" box while the rest of the shell renders on. -describe("createAppQueryClient's auth-invalid wiring", () => { - test("a query throwing UnauthenticatedError calls onAuthInvalid", async () => { - const onAuthInvalid = mock(() => undefined); - const client = createAppQueryClient(onAuthInvalid); - await client - .fetchQuery({ - queryKey: ["probe"], - queryFn: () => { - throw new UnauthenticatedError(); - }, - retry: false, - }) - .catch(() => undefined); - expect(onAuthInvalid).toHaveBeenCalledTimes(1); - }); - - test("a query throwing a 401 ApiQueryError calls onAuthInvalid", async () => { - const onAuthInvalid = mock(() => undefined); - const client = createAppQueryClient(onAuthInvalid); - await client - .fetchQuery({ - queryKey: ["probe-401"], - queryFn: () => { - throw new ApiQueryError("nope", 401); - }, - retry: false, - }) - .catch(() => undefined); - expect(onAuthInvalid).toHaveBeenCalledTimes(1); +describe("pathToQueryKey", () => { + test("maps identity-scoped hub paths onto meKeys", () => { + expect(pathToQueryKey("/api/me")).toEqual(meKeys.profile); + expect(pathToQueryKey("/api/me/principals")).toEqual(meKeys.principals); }); - test("a query failing for an unrelated reason never calls onAuthInvalid", async () => { - const onAuthInvalid = mock(() => undefined); - const client = createAppQueryClient(onAuthInvalid); - await client - .fetchQuery({ - queryKey: ["probe-500"], - queryFn: () => { - throw new ApiQueryError("server exploded", 500); - }, - retry: false, - }) - .catch(() => undefined); - expect(onAuthInvalid).not.toHaveBeenCalled(); + test("maps the pending-approvals list onto a tenant-scoped key", () => { + expect(pathToQueryKey("/api/tenants/tnt_1/approvals")).toEqual( + tenantKeys.pendingApprovals("tnt_1"), + ); }); - test("a mutation that 401s also calls onAuthInvalid", async () => { - const onAuthInvalid = mock(() => undefined); - const client = createAppQueryClient(onAuthInvalid); - const mutation = client.getMutationCache().build(client, { - mutationFn: () => { - throw new UnauthenticatedError(); - }, - }); - await mutation.execute(undefined).catch(() => undefined); - expect(onAuthInvalid).toHaveBeenCalledTimes(1); + test("maps tenant assets onto a tenant-scoped key", () => { + expect(pathToQueryKey("/api/tenants/tnt_1/assets")).toEqual(tenantKeys.assets("tnt_1")); }); - test("defaults to a no-op when no callback is given", async () => { - const client = createAppQueryClient(); - await expect( - client.fetchQuery({ - queryKey: ["probe-default"], - queryFn: () => { - throw new UnauthenticatedError(); - }, - retry: false, - }), - ).rejects.toBeInstanceOf(UnauthenticatedError); + test("falls back to a path key for unknown routes", () => { + expect(pathToQueryKey("/api/mystery")).toEqual(["path", "/api/mystery"]); }); }); -describe("pathToQueryKey", () => { - test("maps the artifacts list under the artifacts key family", () => { - expect(pathToQueryKey(`/api/tenants/${TENANT}/artifacts`)).toEqual([ - ...tenantKeys.artifacts(TENANT), - "", - ]); - }); - - test("maps artifacts/counts under the artifacts key family, not the path fallback", () => { - expect(pathToQueryKey(`/api/tenants/${TENANT}/artifacts/counts`)).toEqual( - tenantKeys.artifactCounts(TENANT), +describe("tenantKeys.routineActivity", () => { + // the sidebar routine-activity seam keeps a shared cache key that + // names the seam — not the deleted `/top-level-runs` route — so both + // mounts subscribe to one entry and a future native fires equivalent has + // a key to rewire. + test("keys routine activity per tenant without the deleted route name", () => { + expect(tenantKeys.routineActivity("tnt_1")).toEqual(["tenant", "tnt_1", "routine-activity"]); + expect((tenantKeys.routineActivity("tnt_1") as readonly unknown[]).join("/")).not.toContain( + "top-level-runs", ); }); }); - -describe("invalidating tenantKeys.artifacts after an upload", () => { - test("also invalidates the counts query, not just the list", async () => { - const queryClient = new QueryClient(); - const listKey = pathToQueryKey(`/api/tenants/${TENANT}/artifacts`); - const countsKey = pathToQueryKey(`/api/tenants/${TENANT}/artifacts/counts`); - - queryClient.setQueryData(listKey, { data: [], nextCursor: null }); - queryClient.setQueryData(countsKey, { - all: 0, - document: 0, - sheet: 0, - pdf: 0, - routine: 0, - }); - - await queryClient.invalidateQueries({ - queryKey: tenantKeys.artifacts(TENANT), - }); - - expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(countsKey)?.isInvalidated).toBe(true); - }); -}); diff --git a/apps/web/test/react-ui-toast-mock.ts b/apps/web/src/react-ui-toast-mock.ts similarity index 100% rename from apps/web/test/react-ui-toast-mock.ts rename to apps/web/src/react-ui-toast-mock.ts diff --git a/apps/web/test/settings-access.test.tsx b/apps/web/src/settings-access.test.tsx similarity index 97% rename from apps/web/test/settings-access.test.tsx rename to apps/web/src/settings-access.test.tsx index 21c5781714..04dd7437cd 100644 --- a/apps/web/test/settings-access.test.tsx +++ b/apps/web/src/settings-access.test.tsx @@ -7,7 +7,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import type { TenancyAccess } from "@/settings"; -import { useSettingsAccess } from "../src/settings-access"; +import { useSettingsAccess } from "./settings-access"; import { createTestQueryClient, TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/settings/access.test.ts b/apps/web/src/settings/access.test.ts similarity index 96% rename from apps/web/test/settings/access.test.ts rename to apps/web/src/settings/access.test.ts index e382e43846..30f7c1a29e 100644 --- a/apps/web/test/settings/access.test.ts +++ b/apps/web/src/settings/access.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { coalesceSectionAccess, probeSectionAccess } from "../../src/settings/access"; +import { coalesceSectionAccess, probeSectionAccess } from "./access"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/settings/credentials-api.test.ts b/apps/web/src/settings/credentials-api.test.ts similarity index 98% rename from apps/web/test/settings/credentials-api.test.ts rename to apps/web/src/settings/credentials-api.test.ts index e1c711f736..3e237b7c1c 100644 --- a/apps/web/test/settings/credentials-api.test.ts +++ b/apps/web/src/settings/credentials-api.test.ts @@ -8,7 +8,7 @@ import { deleteCredential, listCredentials, listProviders, -} from "../../src/settings/credentials-api"; +} from "./credentials-api"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/settings/grant-preview.test.ts b/apps/web/src/settings/grant-preview.test.ts similarity index 91% rename from apps/web/test/settings/grant-preview.test.ts rename to apps/web/src/settings/grant-preview.test.ts index 2b2d6aa663..5f46fe4578 100644 --- a/apps/web/test/settings/grant-preview.test.ts +++ b/apps/web/src/settings/grant-preview.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - expiryIsoFromPreset, - expiryLabelFromPreset, - grantPreviewSentence, -} from "../../src/settings/grant-preview"; +import { expiryIsoFromPreset, expiryLabelFromPreset, grantPreviewSentence } from "./grant-preview"; describe("grantPreviewSentence", () => { test("defaults the subject when no target is chosen", () => { diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index de053cb1ee..a6d996c30f 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { spyOnReactUiToast } from "../../../test/react-ui-toast-mock"; +import { spyOnReactUiToast } from "../../react-ui-toast-mock"; import type { ContextMenuEntry } from "@/shell/context-menu/lib"; import { WORKBENCHES_MUTATED_EVENT } from "@/chat"; import * as errorSink from "@corbits/error-sink"; diff --git a/apps/web/src/shell/library-artifacts.test.ts b/apps/web/src/shell/library-artifacts.test.ts index 8acaacfe17..1f25ef983c 100644 --- a/apps/web/src/shell/library-artifacts.test.ts +++ b/apps/web/src/shell/library-artifacts.test.ts @@ -1,50 +1,67 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { UnauthenticatedError } from "@/lib/api-query"; import { - artifactUploadToast, - copyArtifactLinksActionLabel, - copyArtifactLinksToastLabel, - uploadMimeTypeFromSource, + artifactListRowToSummary, + isArtifactsUnavailableStatus, + mapArtifactListToSummaries, + uploadArtifactFiles, + type ArtifactListRow, } from "./library-artifacts"; -describe("copy-link labels", () => { - test("action label is count-aware", () => { - expect(copyArtifactLinksActionLabel(1)).toBe("Copy link"); - expect(copyArtifactLinksActionLabel(3)).toBe("Copy 3 links"); - }); +const realFetch = globalThis.fetch; - test("toast label is count-aware", () => { - expect(copyArtifactLinksToastLabel(1)).toBe("Link copied"); - expect(copyArtifactLinksToastLabel(3)).toBe("3 links copied"); - }); +afterEach(() => { + globalThis.fetch = realFetch; }); -describe("artifactUploadToast", () => { - test("a single file is confirmed by name", () => { - expect(artifactUploadToast(["q3-report.pdf"])).toBe("Uploaded · q3-report.pdf"); - }); +const sample: ArtifactListRow = { + id: "art_1", + kind: "file", + title: "Quarterly report.pdf", + ownerName: "Ada", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", +}; - test("several files are confirmed by count", () => { - expect(artifactUploadToast(["a.png", "b.png", "c.png", "d.png"])).toBe("Uploaded 4 files"); +describe("library-artifacts", () => { + test("maps list rows onto ArtifactSummary without inventing fields", () => { + expect(artifactListRowToSummary(sample)).toEqual({ + id: "art_1", + title: "Quarterly report.pdf", + kind: "file", + ownerName: "Ada", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); }); -}); -describe("uploadMimeTypeFromSource", () => { - test("reads the mime type off a real upload's source", () => { - expect( - uploadMimeTypeFromSource({ - origin: "library-upload", - upload: { id: "u1", mimeType: "text/markdown", filename: "a.md" }, - }), - ).toBe("text/markdown"); + test("maps a list without inventing or dropping rows", () => { + const second: ArtifactListRow = { + ...sample, + id: "art_2", + kind: "document", + title: "notes.txt", + ownerName: null, + }; + const mapped = mapArtifactListToSummaries([sample, second]); + expect(mapped).toHaveLength(2); + expect(mapped[0]?.id).toBe("art_1"); + expect(mapped[1]?.kind).toBe("document"); + expect(mapped[1]?.ownerName).toBeNull(); }); - test("is null for an artifact with no upload backing", () => { - expect(uploadMimeTypeFromSource({ origin: "chat" })).toBeNull(); + test("detects the unconfigured-plane status", () => { + expect(isArtifactsUnavailableStatus(503)).toBe(true); + expect(isArtifactsUnavailableStatus(500)).toBe(false); + expect(isArtifactsUnavailableStatus(undefined)).toBe(false); }); - test("is null when the upload field is malformed", () => { - expect(uploadMimeTypeFromSource({ upload: "not-an-object" })).toBeNull(); - expect(uploadMimeTypeFromSource({ upload: { id: "u1" } })).toBeNull(); + test("uploadArtifactFiles throws an UnauthenticatedError on 401", async () => { + globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 401 }))) as typeof fetch; + await expect(uploadArtifactFiles("tnt_1", [new File(["x"], "x.txt")])).rejects.toBeInstanceOf( + UnauthenticatedError, + ); }); }); diff --git a/apps/web/test/routine-activity.test.ts b/apps/web/src/shell/routine-activity.test.ts similarity index 94% rename from apps/web/test/routine-activity.test.ts rename to apps/web/src/shell/routine-activity.test.ts index 42afd5ca69..3579ac9d32 100644 --- a/apps/web/test/routine-activity.test.ts +++ b/apps/web/src/shell/routine-activity.test.ts @@ -10,7 +10,7 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { listRoutineActivity } from "../src/shell/routine-activity"; +import { listRoutineActivity } from "./routine-activity"; const realFetch = globalThis.fetch; diff --git a/apps/web/test/test-query-provider.tsx b/apps/web/src/test-query-provider.tsx similarity index 100% rename from apps/web/test/test-query-provider.tsx rename to apps/web/src/test-query-provider.tsx diff --git a/apps/web/test/agent-detail-page.test.tsx b/apps/web/test/agent-detail-page.test.tsx deleted file mode 100644 index 81a3e76ed3..0000000000 --- a/apps/web/test/agent-detail-page.test.tsx +++ /dev/null @@ -1,581 +0,0 @@ -// The agent detail page at `/agents/`: the identity card, -// the system-prompt editor, the skills section, recent runs, and the -// Duplicate/Archive/Save trio in the top bar's action slot. Mounted against -// a stubbed hub so every edit is asserted where it matters — on the request -// body that leaves the page, through the mutations -// `@corbits/agent-directory` already owns, never a write path of this -// page's own. - -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { - AgentDetailPage, - describeSaveReport, - duplicateHandle, - recentRunsForDefinition, - type SaveReport, -} from "../src/pages/agent-detail-page"; -import type { AgentDefinitionWithDisplayName } from "../src/agents-directory"; -import type { AgentDefinitionDetail, AgentInstance, CatalogModel } from "../src/agents-api"; - -const definition: AgentDefinitionWithDisplayName = { - id: "wfd_1", - tenantId: "tnt_1", - name: "triage-bot", - displayName: "Triage bot", - description: "Triage bot", - currentVersion: "v1", - status: "deployed", - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", -}; - -const detail: AgentDefinitionDetail = { - name: "Triage bot", - systemPrompt: "You sort inbound issues.", - model: "claude-sonnet", - skills: ["triage"], -}; - -const models: readonly CatalogModel[] = [ - { - id: "mdl_1", - tenantId: "tnt_1", - canonicalName: "claude-sonnet", - displayName: "Claude Sonnet", - disabled: false, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - } as unknown as CatalogModel, - { - id: "mdl_2", - tenantId: "tnt_1", - canonicalName: "claude-opus", - displayName: "Claude Opus", - disabled: false, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - } as unknown as CatalogModel, -]; - -function run(overrides: Partial = {}): AgentInstance { - return { - id: "run_1", - definitionId: "wfd_1", - definitionName: "triage-bot", - tenantId: "tnt_1", - address: "triage-bot@example.test", - status: "running", - createdAt: "2026-08-10T09:00:00.000Z", - updatedAt: "2026-08-10T09:00:00.000Z", - ...overrides, - } as AgentInstance; -} - -const noop = () => undefined; - -function renderPage(props: Partial[0]> = {}) { - return renderToStaticMarkup( - undefined} - onStatusChanged={noop} - {...props} - />, - ); -} - -describe("recentRunsForDefinition", () => { - test("keeps only this definition's runs, newest first", () => { - const recent = recentRunsForDefinition( - [ - run({ id: "run_old", createdAt: "2026-08-01T00:00:00.000Z" }), - run({ id: "run_other", definitionId: "wfd_other" }), - run({ id: "run_new", createdAt: "2026-08-20T00:00:00.000Z" }), - ], - "wfd_1", - ); - expect(recent.map((entry) => entry.id)).toEqual(["run_new", "run_old"]); - }); -}); - -describe("duplicateHandle", () => { - test("suffixes the original slug, staying kebab-safe", () => { - expect(duplicateHandle("triage-bot")).toBe("triage-bot-copy"); - }); -}); - -describe("AgentDetailPage render", () => { - test("renders the slug immutably in mono beside the editable display name", () => { - const markup = renderPage(); - expect(markup).toContain('id="agent-display-name"'); - expect(markup).toContain('value="Triage bot"'); - expect(markup).toContain("font-mono"); - expect(markup).toContain("triage-bot"); - expect(markup).not.toContain('id="agent-slug"'); - }); - - test("crumbs read Agents → display name, linking back to the roster", () => { - const markup = renderPage(); - expect(markup).toContain('href="/agents"'); - expect(markup).toContain("Triage bot"); - }); - - test("puts Duplicate, Archive, and Save in the top bar's action slot", () => { - const markup = renderPage(); - const actions = markup.split('data-testid="stage-top-bar-actions"')[1] ?? ""; - expect(actions).toContain('aria-label="Duplicate this agent"'); - expect(actions).toContain('aria-label="Archive this agent"'); - expect(actions).toContain('aria-label="Save this agent"'); - }); - - test("an archived agent offers Restore instead of Archive", () => { - const markup = renderPage({ - definition: { ...definition, status: "stopped" }, - }); - expect(markup).toContain('aria-label="Restore this agent"'); - expect(markup).not.toContain('aria-label="Archive this agent"'); - expect(markup).toContain("Archived"); - }); - - test("offers the full system prompt in an editor seeded from the definition", () => { - const markup = renderPage(); - expect(markup).toContain("System prompt"); - expect(markup).toContain("You sort inbound issues."); - }); - - test("lists the agent's own runs, each linking into the Insights run surface", () => { - const markup = renderPage(); - expect(markup).toContain('href="/insights/runs/run_1"'); - }); - - test("teaches what recent runs will hold when the agent has never run", () => { - const markup = renderPage({ runs: [] }); - expect(markup).toContain("No runs yet"); - }); - - test("offers no description field — a definition's description IS its display name", () => { - const markup = renderPage(); - expect(markup).not.toContain('id="agent-description"'); - }); - - // Same agent, one name everywhere: the page titles and seeds its name - // field from `deriveDisplayName`, exactly like the roster row does, so a - // definition with no description reads as its humanized slug on both. - test("titles from the derived display name, never a second naming rule", () => { - const markup = renderPage({ - definition: { - ...definition, - name: "research-analyst", - displayName: "Research Analyst", - description: null, - }, - detail: { ...detail, name: "research-analyst" }, - }); - expect(markup).toContain('value="Research Analyst"'); - expect(markup).not.toContain('value="research-analyst"'); - }); - - test("reports the outcome of the save that produced what is on screen", () => { - const markup = renderPage({ - saveReport: { - saved: ["instructions"], - failed: { part: "skills", message: "The registry is unreachable." }, - }, - }); - expect(markup).toContain("name and system prompt"); - expect(markup).toContain("skills"); - expect(markup).toContain("The registry is unreachable."); - }); - - test("skillsError is an alert above Skills, never silent empty pins", () => { - const markup = renderPage({ skillsError: "500: down" }); - expect(markup).toContain('role="alert"'); - expect(markup).toContain("Could not load agent skills"); - expect(markup).toContain("500: down"); - }); -}); - -describe("describeSaveReport", () => { - test("a clean save names what it wrote", () => { - expect(describeSaveReport({ saved: ["model"], failed: null })).toBe("Saved default model."); - }); - - test("a partial save names both halves, never just the failure", () => { - const sentence = describeSaveReport({ - saved: ["instructions"], - failed: { part: "model", message: "Something went wrong." }, - }); - expect(sentence).toContain("Saved name and system prompt"); - expect(sentence).toContain("default model"); - }); -}); - -// --- Edits round-tripping through the API --- - -const realFetch = globalThis.fetch; -let requests: { method: string; url: string; body: unknown }[] = []; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function stubFetch(): void { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const body = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; - requests.push({ method: init?.method ?? "GET", url, body }); - if (url.endsWith("/skills") && (init?.method ?? "GET") === "GET") { - return Promise.resolve(json({ skills: [] })); - } - if (url.includes("/skills?")) { - return Promise.resolve(json({ data: [], nextCursor: null })); - } - if (url.endsWith("/skills")) { - return Promise.resolve(json({ skills: ["triage"] })); - } - if (url.endsWith("/capabilities")) { - return Promise.resolve(json({ skills: ["triage"], model: "claude-opus" })); - } - if (url.endsWith("/status")) { - return Promise.resolve(json({ id: "wfd_1", status: "stopped" })); - } - if (url.endsWith("/agent-definitions")) { - return Promise.resolve( - json( - { - id: "wfd_2", - tenantId: "tnt_1", - name: "triage-bot-copy", - description: "Triage bot copy", - currentVersion: "v1", - status: "deployed", - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-20T00:00:00.000Z", - skills: ["triage"], - }, - 201, - ), - ); - } - return Promise.resolve(json({ name: "Triage bot", systemPrompt: "You sort inbound issues." })); - }) as typeof fetch; -} - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -beforeEach(() => { - requests = []; - stubFetch(); -}); - -afterEach(() => { - globalThis.fetch = realFetch; - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - container?.remove(); - container = null; -}); - -async function mount(overrides: Partial[0]> = {}) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - undefined} - onStatusChanged={noop} - {...overrides} - />, - ); - }); - await act(async () => { - await Promise.resolve(); - }); - return container; -} - -function setValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( - element instanceof HTMLTextAreaElement - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(element, value); - element.dispatchEvent(new Event("input", { bubbles: true })); -} - -function byLabel(node: HTMLElement, label: string): HTMLElement { - const found = node.querySelector(`[aria-label="${label}"]`); - if (found === null) throw new Error(`no element labelled "${label}"`); - return found; -} - -describe("AgentDetailPage edits", () => { - test("Save is inert until something actually changes", async () => { - const node = await mount(); - const save = byLabel(node, "Save this agent") as HTMLButtonElement; - expect(save.disabled).toBe(true); - }); - - test("a renamed agent with a rewritten prompt saves through the instructions route", async () => { - let saved = 0; - const node = await mount({ onSaved: () => (saved += 1) }); - const name = node.querySelector("#agent-display-name"); - const prompt = node.querySelector("#agent-system-prompt"); - if (name === null || prompt === null) throw new Error("editor not mounted"); - - await act(async () => { - setValue(name, "Inbox triage"); - setValue(prompt, "You sort inbound issues, briskly."); - }); - await act(async () => { - byLabel(node, "Save this agent").click(); - await Promise.resolve(); - }); - - const put = requests.find( - (request) => - request.method === "PUT" && request.url === "/api/tenants/tnt_1/agent-definitions/wfd_1", - ); - expect(put?.body).toEqual({ - name: "Inbox triage", - systemPrompt: "You sort inbound issues, briskly.", - }); - expect(saved).toBe(1); - }); - - test("a changed default model saves through the guided capability route, not a new one", async () => { - const node = await mount(); - const select = node.querySelector("#agent-model"); - if (select === null) throw new Error("model select not mounted"); - - await act(async () => { - select.value = "claude-opus"; - select.dispatchEvent(new Event("change", { bubbles: true })); - }); - await act(async () => { - byLabel(node, "Save this agent").click(); - await Promise.resolve(); - }); - - const post = requests.find((request) => - request.url.endsWith("/agent-definitions/wfd_1/capabilities"), - ); - expect(post?.body).toEqual({ kind: "model", canonicalName: "claude-opus" }); - // An untouched field is never rewritten. - expect( - requests.some( - (request) => - request.url === "/api/tenants/tnt_1/agent-definitions/wfd_1" && request.method === "PUT", - ), - ).toBe(false); - }); - - test("Duplicate creates a second definition from this one's authored state", async () => { - const duplicated: string[] = []; - const node = await mount({ - onDuplicated: async (slug: string) => { - duplicated.push(slug); - }, - }); - await act(async () => { - byLabel(node, "Duplicate this agent").click(); - await Promise.resolve(); - }); - - const post = requests.find((request) => request.url === "/api/tenants/tnt_1/agent-definitions"); - expect(post?.body).toEqual({ - name: "Triage bot copy", - handle: "triage-bot-copy", - systemPrompt: "You sort inbound issues.", - model: "claude-sonnet", - skills: ["triage"], - }); - expect(duplicated).toEqual(["triage-bot-copy"]); - }); - - test("Archive takes two clicks and writes the stopped status, never a delete", async () => { - let changed = 0; - const node = await mount({ onStatusChanged: () => (changed += 1) }); - const archive = byLabel(node, "Archive this agent"); - - await act(async () => { - archive.click(); - }); - expect(requests.some((request) => request.url.endsWith("/status"))).toBe(false); - - await act(async () => { - archive.click(); - await Promise.resolve(); - }); - - const put = requests.find((request) => request.url.endsWith("/status")); - expect(put?.method).toBe("PUT"); - expect(put?.body).toEqual({ status: "stopped" }); - expect(requests.some((request) => request.method === "DELETE")).toBe(false); - expect(changed).toBe(1); - }); - - test("a failed save reports which part failed, so the caller can reload honestly", async () => { - const reports: SaveReport[] = []; - const node = await mount({ - onSaved: (report: SaveReport) => reports.push(report), - }); - globalThis.fetch = (() => - Promise.resolve( - json({ error: { message: "The agent is locked." } }, 500), - )) as unknown as typeof fetch; - const prompt = node.querySelector("#agent-system-prompt"); - if (prompt === null) throw new Error("editor not mounted"); - - await act(async () => { - setValue(prompt, "New instructions."); - }); - await act(async () => { - byLabel(node, "Save this agent").click(); - await Promise.resolve(); - }); - - expect(reports).toHaveLength(1); - expect(reports[0]?.saved).toEqual([]); - expect(reports[0]?.failed?.part).toBe("instructions"); - }); - - test("selecting Bench default on a pinned agent is a real edit that clears the model", async () => { - const node = await mount(); - const select = node.querySelector("#agent-model"); - if (select === null) throw new Error("model select not mounted"); - - await act(async () => { - select.value = ""; - select.dispatchEvent(new Event("change", { bubbles: true })); - }); - const save = byLabel(node, "Save this agent") as HTMLButtonElement; - expect(save.disabled).toBe(false); - - await act(async () => { - save.click(); - await Promise.resolve(); - }); - - const cleared = requests.find( - (request) => request.url === "/api/tenants/tnt_1/agent-definitions/wfd_1/capabilities/model", - ); - expect(cleared?.method).toBe("DELETE"); - // Never the inventory-checked add route with an empty name. - expect( - requests.some( - (request) => - request.url.endsWith("/capabilities") && - (request.body as { canonicalName?: string } | undefined)?.canonicalName === "", - ), - ).toBe(false); - }); - - test("a Save that lands partway names the parts that committed", async () => { - const reports: SaveReport[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const body = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; - requests.push({ method: init?.method ?? "GET", url, body }); - if (url.endsWith("/capabilities")) { - return Promise.resolve(json({ error: { message: "model gone" } }, 500)); - } - if (url.endsWith("/skills")) return Promise.resolve(json({ skills: [] })); - return Promise.resolve(json({ name: "Inbox triage", systemPrompt: "New instructions." })); - }) as unknown as typeof fetch; - - const node = await mount({ - onSaved: (report: SaveReport) => reports.push(report), - }); - const prompt = node.querySelector("#agent-system-prompt"); - const select = node.querySelector("#agent-model"); - if (prompt === null || select === null) { - throw new Error("editor not mounted"); - } - - await act(async () => { - setValue(prompt, "New instructions."); - select.value = "claude-opus"; - select.dispatchEvent(new Event("change", { bubbles: true })); - }); - await act(async () => { - byLabel(node, "Save this agent").click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(reports[0]?.saved).toEqual(["instructions"]); - expect(reports[0]?.failed?.part).toBe("model"); - expect(describeSaveReport(reports[0] as SaveReport)).toContain("name and system prompt"); - }); - - test("a duplicate whose handle already exists says what collided, never 'try again'", async () => { - globalThis.fetch = ((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/agent-definitions")) { - return Promise.resolve( - json( - { - error: { - code: "conflict", - message: 'An agent with the handle "triage-bot-copy" already exists', - }, - }, - 409, - ), - ); - } - return Promise.resolve(json({ skills: [] })); - }) as unknown as typeof fetch; - - const node = await mount(); - await act(async () => { - byLabel(node, "Duplicate this agent").click(); - await Promise.resolve(); - }); - - expect(node.textContent).toContain("triage-bot-copy"); - expect(node.textContent).toContain("already exists"); - expect(node.textContent).not.toContain("Try again"); - }); - - test("Duplicate waits for unsaved edits rather than silently copying the saved version", async () => { - const node = await mount(); - const prompt = node.querySelector("#agent-system-prompt"); - if (prompt === null) throw new Error("editor not mounted"); - - await act(async () => { - setValue(prompt, "Edited, not yet saved."); - }); - - const duplicate = byLabel(node, "Duplicate this agent") as HTMLButtonElement; - expect(duplicate.disabled).toBe(true); - expect(node.textContent).toContain("Duplicate copies the saved version"); - }); -}); diff --git a/apps/web/test/agent-skills-picker.test.tsx b/apps/web/test/agent-skills-picker.test.tsx deleted file mode 100644 index 1dd1f6f0d6..0000000000 --- a/apps/web/test/agent-skills-picker.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// AgentSkillsPicker: a pin can outlive its skill — it was -// renamed or discarded — so a stale name in `selected` must still render as -// a removable row rather than vanish silently and leave the dialog -// unsaveable. - -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { AgentSkillsPicker } from "../src/pages/agent-skills-picker"; - -const TENANT = "tnt_1"; - -const TRIAGE = { - id: "ast_1", - tenantId: "tnt_1", - kind: "skill", - name: "triage", - displayName: "Triage", - creatorPrincipalId: "prn_1", - createdAt: "2026-08-05T11:00:00.000Z", - updatedAt: "2026-08-05T11:00:00.000Z", -}; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; -const originalFetch = globalThis.fetch; - -function stubSkills(skills: readonly unknown[]): void { - globalThis.fetch = (async () => - new Response(JSON.stringify(skills), { - status: 200, - })) as unknown as typeof fetch; -} - -beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; - if (root !== null) { - act(() => { - root?.unmount(); - }); - root = null; - } - container?.remove(); - container = null; -}); - -async function mount(props: { - readonly selected: readonly string[]; - readonly onChange: (next: readonly string[]) => void; -}) { - if (container === null) throw new Error("container missing"); - root = createRoot(container); - await act(async () => { - root?.render( - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - return container; -} - -describe("AgentSkillsPicker", () => { - test("a pin to a skill still visible renders as a normal checked checkbox", async () => { - stubSkills([TRIAGE]); - const el = await mount({ selected: ["triage"], onChange: () => {} }); - const checkbox = el.querySelector("input[type=checkbox]"); - expect(checkbox?.checked).toBe(true); - expect(el.textContent).not.toContain("No longer available"); - }); - - test("a pin to a skill the caller can no longer see renders as a removable stale row", async () => { - stubSkills([TRIAGE]); - const el = await mount({ - selected: ["triage", "gone-private"], - onChange: () => {}, - }); - expect(el.textContent).toContain("gone-private"); - expect(el.textContent).toContain("No longer available"); - const remove = Array.from(el.querySelectorAll("button")).find( - (button) => button.textContent === "Remove", - ); - expect(remove).toBeDefined(); - }); - - test("removing a stale pin and saving reaches a clean, saveable state", async () => { - stubSkills([]); - let next: readonly string[] = []; - const el = await mount({ - selected: ["gone-private"], - onChange: (updated) => { - next = updated; - }, - }); - expect(el.textContent).toContain("gone-private"); - const remove = Array.from(el.querySelectorAll("button")).find( - (button) => button.textContent === "Remove", - ); - await act(async () => { - remove?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(next).toEqual([]); - }); - - test("with no skills and no stale pins, the empty state explains there is nothing to attach", async () => { - stubSkills([]); - const el = await mount({ selected: [], onChange: () => {} }); - expect(el.textContent).toContain("No skills yet"); - }); -}); diff --git a/apps/web/test/agents-api.test.ts b/apps/web/test/agents-api.test.ts deleted file mode 100644 index 28d0e7b07a..0000000000 --- a/apps/web/test/agents-api.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -// The Agents page directory client: model catalog path/shape and failure -// isolation so a broken picker never blanks definitions and instances. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { ApiQueryError } from "@/lib/api-query"; - -import { - clearAgentModel, - draftAgentDefinition, - getAgentDefinitionBySlug, - listCatalogModels, - loadAgentDirectory, - updateAgentSkills, -} from "../src/agents-api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly method: string }; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const full = - typeof input === "string" - ? input - : `${new URL(String(input)).pathname}${new URL(String(input)).search}`; - const path = typeof input === "string" ? input : full; - calls.push({ path, method: init?.method ?? "GET" }); - // Matchers key on the path-with-query the client builds. - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -const modelFixture = { - id: "mdl_1", - tenantId: "tnt_1", - canonicalName: "anthropic/claude-sonnet-4", - displayName: "Claude Sonnet 4", - description: null, - disabled: false, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", -}; - -const disabledModel = { - ...modelFixture, - id: "mdl_2", - canonicalName: "disabled/model", - disabled: true, -}; - -const definitionFixture = { - id: "wfd_1", - tenantId: "tnt_1", - name: "Researcher", - description: "Answers research questions", - currentVersion: "1", - status: "deployed" as const, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", -}; - -const instanceFixture = { - id: "ins_1", - definitionId: "wfd_1", - definitionName: "Researcher", - tenantId: "tnt_1", - address: "ins_1@acme.localhost", - status: "running" as const, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", -}; - -describe("listCatalogModels", () => { - test("fetches the paginated catalog models endpoint", async () => { - const calls = stubFetch((path) => { - expect(path.startsWith("/api/tenants/tnt_1/catalog/models")).toBe(true); - return json({ data: [modelFixture, disabledModel], nextCursor: null }); - }); - - const models = await listCatalogModels("tnt_1"); - expect(calls[0]?.path).toContain("/api/tenants/tnt_1/catalog/models"); - expect(models).toEqual([modelFixture]); - }); - - test("drops embedding-named, hf.co, and .gguf catalog rows from the picker", async () => { - const embedding = { - ...modelFixture, - id: "mdl_embed", - canonicalName: "all-minilm", - displayName: "all-minilm", - }; - const hfPath = { - ...modelFixture, - id: "mdl_hf", - canonicalName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", - displayName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", - }; - const gguf = { - ...modelFixture, - id: "mdl_gguf", - canonicalName: "model.Q4_K_M.gguf", - displayName: "model.Q4_K_M.gguf", - }; - stubFetch(() => - json({ - data: [modelFixture, embedding, hfPath, gguf, disabledModel], - nextCursor: null, - }), - ); - - const models = await listCatalogModels("tnt_1"); - expect(models).toEqual([modelFixture]); - }); - - test("rejects the bare-array discovery shape the wrong endpoint returns", async () => { - stubFetch(() => - json([ - { - id: "mdl_1", - canonicalName: "anthropic/claude-sonnet-4", - offerings: [], - }, - ]), - ); - - await expect(listCatalogModels("tnt_1")).rejects.toThrow(/Unexpected response shape/); - }); -}); - -describe("loadAgentDirectory", () => { - test("loads definitions and instances even when the model catalog fails", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [definitionFixture], nextCursor: null }); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ error: { message: "catalog down" } }, 503); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - const directory = await loadAgentDirectory("tnt_1"); - expect(directory.definitions).toEqual([definitionFixture]); - expect(directory.instances).toEqual([instanceFixture]); - expect(directory.models).toEqual([]); - expect(directory.modelsError).toMatch(/503|catalog/i); - }); - - test("surfaces a definitions failure as a hard error", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ error: { message: "nope" } }, 500); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ data: [modelFixture], nextCursor: null }); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - await expect(loadAgentDirectory("tnt_1")).rejects.toThrow(/500/); - }); - - test("returns ready models when the catalog succeeds", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [definitionFixture], nextCursor: null }); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ data: [modelFixture], nextCursor: null }); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - const directory = await loadAgentDirectory("tnt_1"); - expect(directory.models).toEqual([modelFixture]); - expect(directory.modelsError).toBeUndefined(); - }); - - test("carries each definition's attached skills", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [definitionFixture], nextCursor: null }); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ data: [modelFixture], nextCursor: null }); - } - if (path.includes("/agent-definitions/skills")) { - return json({ skills: { wfd_1: ["web-research"] } }); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - const directory = await loadAgentDirectory("tnt_1"); - expect(directory.definitionSkills).toEqual({ wfd_1: ["web-research"] }); - expect(directory.skillsError).toBeUndefined(); - }); - - test("a broken skills endpoint keeps the page and surfaces skillsError, never silent empty", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [definitionFixture], nextCursor: null }); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ data: [modelFixture], nextCursor: null }); - } - if (path.includes("/agent-definitions/skills")) { - return json({ error: { message: "down" } }, 500); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - const directory = await loadAgentDirectory("tnt_1"); - expect(directory.definitions).toEqual([definitionFixture]); - expect(directory.instances).toEqual([instanceFixture]); - expect(directory.definitionSkills).toEqual({}); - expect(directory.skillsError).toMatch(/500|down/i); - }); - - // instances come from the native tenant-scoped - // `GET /workflows/runs` top-level listing — the deleted `/top-level-runs` - // route (and its `feed=fires` variant) no longer exists. - test("reads instances from the native top-level GET /workflows/runs listing", async () => { - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [definitionFixture], nextCursor: null }); - } - if (path.includes("/workflows/runs")) { - return json({ data: [instanceFixture], nextCursor: null }); - } - if (path.includes("/catalog/models")) { - return json({ data: [modelFixture], nextCursor: null }); - } - return json({ error: { message: "unexpected" } }, 500); - }); - - const directory = await loadAgentDirectory("tnt_1"); - expect(directory.instances).toEqual([instanceFixture]); - expect(calls.some((c) => c.path.includes("/workflows/runs"))).toBe(true); - expect(calls.some((c) => c.path.includes("/top-level-runs"))).toBe(false); - }); -}); - -describe("updateAgentSkills", () => { - test("PUTs the full replacement skill set and returns it back", async () => { - const calls = stubFetch((path) => { - expect(path).toBe("/api/tenants/tnt_1/agent-definitions/wfd_1/skills"); - return json({ skills: ["web-research"] }); - }); - - const skills = await updateAgentSkills("tnt_1", "wfd_1", ["web-research"]); - expect(calls.length).toBe(1); - expect(calls[0]?.method).toBe("PUT"); - expect(skills).toEqual(["web-research"]); - }); -}); - -describe("getAgentDefinitionBySlug", () => { - // A slug-addressed page must not depend on the definition being inside - // the listing's first (and only) page — it asks the server by name. - test("resolves one definition by name, never through the paginated listing", async () => { - const calls = stubFetch((path) => { - expect(path).toBe("/api/tenants/tnt_1/agent-definitions/by-name/triage-bot"); - return json({ - id: "wfd_1", - tenantId: "tnt_1", - name: "triage-bot", - description: "Triage bot", - currentVersion: "1", - status: "deployed", - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }); - }); - - const definition = await getAgentDefinitionBySlug("tnt_1", "triage-bot"); - expect(definition.id).toBe("wfd_1"); - expect(calls.every((call) => !call.path.includes("limit="))).toBe(true); - }); -}); - -describe("clearAgentModel", () => { - test("DELETEs the model capability rather than posting an empty name", async () => { - const calls = stubFetch((path) => { - expect(path).toBe("/api/tenants/tnt_1/agent-definitions/wfd_1/capabilities/model"); - return json({ skills: [] }); - }); - - await clearAgentModel("tnt_1", "wfd_1"); - expect(calls[0]?.method).toBe("DELETE"); - }); -}); - -const DRAFT_FAILED_MESSAGE = - "Myra couldn't draft a starting prompt for that. Write one yourself, or try again."; - -describe("draftAgentDefinition errors", () => { - test("keeps the envelope userMessage and refId on a 422", async () => { - stubFetch(() => - json( - { - error: { - code: "drafting_failed", - userMessage: DRAFT_FAILED_MESSAGE, - refId: "ref_draft_1", - }, - }, - 422, - ), - ); - - try { - await draftAgentDefinition("tnt_1", { name: "Research Buddy" }); - throw new Error("expected draftAgentDefinition to reject"); - } catch (err) { - expect(err).toBeInstanceOf(ApiQueryError); - const error = err as ApiQueryError; - expect(error.message).toBe(DRAFT_FAILED_MESSAGE); - expect(error.refId).toBe("ref_draft_1"); - expect(error.status).toBe(422); - } - }); - - test("keeps the envelope userMessage and refId on a 500", async () => { - stubFetch(() => - json( - { - error: { - code: "internal_error", - userMessage: "Something went wrong. Please try again.", - refId: "ref_sink_1", - }, - }, - 500, - ), - ); - - try { - await draftAgentDefinition("tnt_1", { name: "Research Buddy" }); - throw new Error("expected draftAgentDefinition to reject"); - } catch (err) { - expect(err).toBeInstanceOf(ApiQueryError); - const error = err as ApiQueryError; - expect(error.message).toBe("Something went wrong. Please try again."); - expect(error.refId).toBe("ref_sink_1"); - expect(error.status).toBe(500); - } - }); - - test("does not treat a legacy {code, message} body as an envelope", async () => { - stubFetch(() => - json( - { - error: { - code: "drafting_failed", - message: "MyraAgentDefinitionDraftingUnavailableError: no myra\n at draft", - }, - }, - 422, - ), - ); - - try { - await draftAgentDefinition("tnt_1", { name: "Research Buddy" }); - throw new Error("expected draftAgentDefinition to reject"); - } catch (err) { - expect(err).toBeInstanceOf(ApiQueryError); - const error = err as ApiQueryError; - expect(error.message).toBe("The server answered 422."); - expect(error.refId).toBeUndefined(); - expect(error.message).not.toContain("no myra"); - } - }); -}); diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx deleted file mode 100644 index a277f98f68..0000000000 --- a/apps/web/test/agents-page.test.tsx +++ /dev/null @@ -1,618 +0,0 @@ -// Agents roster: a flat table of every definition a -// bench owns — rows, never cards — with Status/Model/Runs·7d columns and a -// bulk-select bar. `AgentsPage` is the presentational half (same split -// `LibraryPage`/`LibraryRoute` use in `pages.test.tsx`); no live hub here, -// just the render given real-shaped props. - -import { describe, expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { - AgentsPage, - AgentModelCellView, - agentModelSettledContent, - agentRosterStatus, - agentRunsSettledContent, - archiveDefinitions, - archiveResultToast, - runsInLast7Days, -} from "../src/pages/agents-page"; -import type { AgentDefinitionWithDisplayName } from "../src/agents-directory"; -import type { AgentInstance } from "../src/agents-api"; - -// `name` is the immutable kebab identifier (the URL slug); `displayName` is -// what `withDisplayNames` derives from the definition's -// description (or, absent one, a humanized reading of `name`). The roster -// name slot is displayName only — the slug stays the URL, not a -// second line under the name. -const triage: AgentDefinitionWithDisplayName = { - id: "wfd_1", - tenantId: "tnt_1", - name: "triage-bot", - displayName: "Triage bot", - description: "Sorts inbound issues.", - currentVersion: "v1", - status: "deployed", - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", -}; - -function instance( - overrides: Partial & { readonly definitionId: string }, -): AgentInstance { - return { - id: "run_1", - definitionName: "triage-bot", - tenantId: "tnt_1", - address: "run_1@bench", - status: "running", - createdAt: "2026-08-19T00:00:00.000Z", - updatedAt: "2026-08-19T00:00:00.000Z", - ...overrides, - }; -} - -const NOW = new Date("2026-08-20T00:00:00.000Z").getTime(); - -const noop = () => undefined; - -describe("agentRosterStatus", () => { - test("a stopped definition always reads Archived, regardless of its instances", () => { - expect( - agentRosterStatus({ ...triage, status: "stopped" }, [ - instance({ definitionId: "wfd_1", status: "running" }), - ]), - ).toBe("archived"); - }); - - test("a deployed definition with a running instance reads Running", () => { - expect( - agentRosterStatus(triage, [instance({ definitionId: "wfd_1", status: "running" })]), - ).toBe("running"); - }); - - test("a deployed definition with only an erroring instance reads Blocked", () => { - expect(agentRosterStatus(triage, [instance({ definitionId: "wfd_1", status: "error" })])).toBe( - "blocked", - ); - }); - - test("a deployed definition with no live instances reads Idle", () => { - expect(agentRosterStatus(triage, [])).toBe("idle"); - }); -}); - -describe("runsInLast7Days", () => { - test("counts only this definition's instances created within the trailing week", () => { - const instances = [ - instance({ - definitionId: "wfd_1", - createdAt: "2026-08-19T00:00:00.000Z", - }), - instance({ - definitionId: "wfd_1", - createdAt: "2026-08-01T00:00:00.000Z", - }), - instance({ - definitionId: "wfd_other", - createdAt: "2026-08-19T00:00:00.000Z", - }), - ]; - expect(runsInLast7Days("wfd_1", instances, NOW)).toBe(1); - }); -}); - -describe("agentModelSettledContent", () => { - test("a fetch failure is an error, never the same label as an unset model", () => { - expect( - agentModelSettledContent( - { - status: "error", - message: "Something went wrong loading this agent's model. Try again.", - }, - [], - ), - ).toEqual({ - kind: "error", - message: "Something went wrong loading this agent's model. Try again.", - }); - expect( - agentModelSettledContent( - { - status: "ready", - data: { name: "triage-bot" }, - }, - [], - ), - ).toEqual({ kind: "model", label: "Default" }); - }); - - test("an unset model stays Default even when the catalog has a tenant default", () => { - expect( - agentModelSettledContent( - { - status: "ready", - data: { name: "triage-bot" }, - }, - [{ canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }], - ), - ).toEqual({ kind: "model", label: "Default" }); - }); - - test("an unset model stays Default when the catalog winner changes", () => { - const unset = { - status: "ready" as const, - data: { name: "triage-bot" }, - }; - expect( - agentModelSettledContent(unset, [ - { canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }, - ]), - ).toEqual({ kind: "model", label: "Default" }); - expect( - agentModelSettledContent(unset, [ - { canonicalName: "claude-haiku-5", displayName: "Claude Haiku 5" }, - { canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }, - ]), - ).toEqual({ kind: "model", label: "Default" }); - }); - - test("a stored model is unchanged when the catalog default becomes a different winner", () => { - const stored = { - status: "ready" as const, - data: { name: "triage-bot", model: "claude-sonnet-4" }, - }; - expect( - agentModelSettledContent(stored, [ - { canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }, - { canonicalName: "claude-haiku-5", displayName: "Claude Haiku 5" }, - ]), - ).toEqual({ kind: "model", label: "Claude Sonnet 4" }); - expect( - agentModelSettledContent(stored, [ - { canonicalName: "claude-haiku-5", displayName: "Claude Haiku 5" }, - { canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }, - ]), - ).toEqual({ kind: "model", label: "Claude Sonnet 4" }); - }); - - test("a ready model maps to the catalog displayName, not the raw id", () => { - expect( - agentModelSettledContent( - { - status: "ready", - data: { name: "triage-bot", model: "claude-sonnet-4" }, - }, - [{ canonicalName: "claude-sonnet-4", displayName: "Claude Sonnet 4" }], - ), - ).toEqual({ kind: "model", label: "Claude Sonnet 4" }); - }); - - test("a canonical with no catalog displayName is shown as-is, never an invented name", () => { - expect( - agentModelSettledContent( - { - status: "ready", - data: { name: "triage-bot", model: "claude-sonnet-4" }, - }, - [], - ), - ).toEqual({ kind: "model", label: "claude-sonnet-4" }); - }); -}); - -describe("AgentModelCellView", () => { - test("a capabilities fetch error is visually distinct from an unset model", () => { - const errorMarkup = renderToStaticMarkup( - , - ); - const unsetMarkup = renderToStaticMarkup( - , - ); - expect(errorMarkup).toContain("text-destructive"); - expect(errorMarkup).toContain('role="alert"'); - expect(errorMarkup).toContain("loading this agent's model"); - expect(errorMarkup).not.toContain("text-muted-foreground"); - expect(unsetMarkup).toContain("Default"); - expect(unsetMarkup).toContain("text-muted-foreground"); - expect(unsetMarkup).not.toContain("text-destructive"); - // The muted em-dash is the absent-value glyph elsewhere on the page — - // a fetch failure must not reuse it. - expect(errorMarkup).not.toContain(">—<"); - }); - - test("a ready model renders the catalog displayName, not a monospaced id", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Claude Sonnet 4"); - expect(markup).not.toContain("claude-sonnet-4"); - expect(markup).not.toContain("font-mono"); - }); -}); - -describe("agentRunsSettledContent", () => { - test("a runs fetch failure is not the same as an honest empty history", () => { - expect(agentRunsSettledContent("wfd_1", [], NOW, "Couldn't load run history")).toEqual({ - kind: "error", - message: "Couldn't load run history", - }); - expect(agentRunsSettledContent("wfd_1", [], NOW, null)).toEqual({ - kind: "count", - value: 0, - }); - }); - - test("a successful fetch still reports the trailing-week count", () => { - expect( - agentRunsSettledContent( - "wfd_1", - [ - instance({ - definitionId: "wfd_1", - createdAt: "2026-08-19T00:00:00.000Z", - }), - ], - NOW, - null, - ), - ).toEqual({ kind: "count", value: 1 }); - }); -}); - -describe("archiveDefinitions", () => { - test("one id failing does not roll back or hide the ids that succeeded", async () => { - const result = await archiveDefinitions(["wfd_1", "wfd_2", "wfd_3"], (id) => - id === "wfd_2" ? Promise.reject(new Error("504")) : Promise.resolve(undefined), - ); - expect(result.succeededIds).toEqual(["wfd_1", "wfd_3"]); - expect(result.failedIds).toEqual(["wfd_2"]); - }); - - test("every id succeeding reports no failures", async () => { - const result = await archiveDefinitions(["wfd_1", "wfd_2"], () => Promise.resolve(undefined)); - expect(result.succeededIds).toEqual(["wfd_1", "wfd_2"]); - expect(result.failedIds).toEqual([]); - }); - - test("every id failing reports no successes", async () => { - const result = await archiveDefinitions(["wfd_1", "wfd_2"], () => - Promise.reject(new Error("504")), - ); - expect(result.succeededIds).toEqual([]); - expect(result.failedIds).toEqual(["wfd_1", "wfd_2"]); - }); -}); - -describe("archiveResultToast", () => { - test("reports an honest partial count rather than a blanket success or failure", () => { - expect( - archiveResultToast({ - succeededIds: ["a", "b", "c", "d"], - failedIds: ["e"], - }), - ).toBe("Archived 4 of 5 — the rest failed"); - }); - - test("reports full success", () => { - expect(archiveResultToast({ succeededIds: ["a"], failedIds: [] })).toBe("Archived 1 agent"); - expect(archiveResultToast({ succeededIds: ["a", "b"], failedIds: [] })).toBe( - "Archived 2 agents", - ); - }); - - test("reports full failure", () => { - expect(archiveResultToast({ succeededIds: [], failedIds: ["a"] })).toBe( - "Couldn't archive that agent", - ); - expect(archiveResultToast({ succeededIds: [], failedIds: ["a", "b"] })).toBe( - "Couldn't archive those agents", - ); - }); -}); - -describe("AgentsPage", () => { - test("teaches what will appear once a bench has no agents yet", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("No agents yet"); - }); - - test("renders one row per definition with its display name and Live status, not the slug", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Triage bot"); - expect(markup).not.toContain("triage-bot"); - expect(markup).toContain("Sorts inbound issues."); - expect(markup).toContain("Live"); - expect(markup).not.toContain("Running"); - // Live carries the live dot (react-ui's StatusDot, `live` prop) — - // the spec's liveness marker for an actively-running agent. - expect(markup).toContain('aria-label="Live"'); - // Badge must not shout via CSS uppercase (same as skills). - expect(markup).toContain("normal-case"); - }); - - test("renders a humanized display name for a definition with no description, not its slug", () => { - const undescribed: AgentDefinitionWithDisplayName = { - ...triage, - id: "wfd_undescribed", - name: "research-analyst", - displayName: "Research Analyst", - description: null, - }; - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Research Analyst"); - expect(markup).not.toContain("research-analyst"); - }); - - test("selecting a definition links each workbench instance to its own settings Agents tab", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('href="/w/wb_1/settings/agents"'); - expect(markup).toContain('href="/w/wb_2/settings/agents"'); - expect(markup).toContain(">Growth<"); - expect(markup).toContain(">Support<"); - }); - - // the roster's quick-peek panel must hop to the agent's own page - // at `/agents/` — panels preview enough to decide whether to open - // the full page (DESIGN.md), never substitute for one. - test("selected definition's panel offers a hop to /agents/", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('href="/agents/triage-bot"'); - expect(markup).toContain("Open"); - expect(markup).toContain("Deployed"); - expect(markup).not.toContain(">deployed<"); - expect(markup).not.toContain(">DEPLOYED<"); - expect(markup).toContain("normal-case"); - }); - - test("offers New agent as the top-bar create action, per the top-nav page-action contract", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('aria-label="Create an agent"'); - expect(markup).toContain("New agent"); - }); - - test("no create affordance without a resolved bench", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).not.toContain('aria-label="Create an agent"'); - }); - - test("carries a selection checkbox per row and a header select-all, but no bulk bar with nothing selected", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('aria-label="Select all agents"'); - expect(markup).toContain('aria-label="Select Triage bot"'); - // BulkActionBar renders nothing at count 0 — none of its labels should - // leak into the page while nothing is selected. - expect(markup).not.toContain("Duplicate"); - expect(markup).not.toContain("Move"); - expect(markup).not.toContain("Delete"); - expect(markup).not.toContain("data-bulk-action"); - }); - - test("skillsError is an alert, never silent 'no skills'", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('role="alert"'); - expect(markup).toContain("Could not load agent skills"); - expect(markup).toContain("500: down"); - }); - - test("without skillsError, the skills failure alert is absent", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).not.toContain("Could not load agent skills"); - }); - - test("an honest empty run history shows 0, not a load-failure marker", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Idle"); - expect(markup).toContain(">0<"); - expect(markup).not.toContain("Couldn't load run history"); - }); - - test("a runs fetch failure is distinguishable from zero instances", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("text-destructive"); - expect(markup).toContain('role="alert"'); - expect(markup).toContain("Something went wrong loading run history. Try again."); - // Must not claim an Idle status or a literal zero off fabricated empty - // instances — that is the dishonest path `runsQuery.data ?? []` produced. - expect(markup).not.toContain("Idle"); - expect(markup).not.toContain(">0<"); - }); -}); diff --git a/apps/web/test/api.test.tsx b/apps/web/test/api.test.tsx deleted file mode 100644 index b67e3710ef..0000000000 --- a/apps/web/test/api.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// The approval mutations this app's approvals page now drives: approve is -// always scope "once" (the hub 400s on "always" — see -// vendor/intx/hub-api/src/routes/approvals.ts), and reject carries an -// optional message. Stub global fetch and assert both the request made and -// how the response is parsed, the same convention the settings-ui and -// bench-ui API clients are tested with. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { type } from "arktype"; - -import type { APIQuery } from "@/lib/api-query"; -import { ApiQueryError } from "@/lib/api-query"; - -import { approveApproval, rejectApproval, useAPIQuery } from "../src/api"; -import { TestQueryProvider } from "./test-query-provider"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -const approvalFixture = { - id: "apr_1", - tenantId: "tnt_1", - anchorRunId: "run_anchor1", - runId: "run_1", - agentAddress: "agent://tnt_1/billing-bot", - correlationId: "corr_1", - toolDefinition: { name: "send_email" }, - toolArguments: { to: "person@example.com" }, - scope: "once", - status: "approved", - timeoutAt: null, - resolvedAt: "2026-01-01T00:00:00.000Z", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", -}; - -describe("approveApproval", () => { - test("always sends scope 'once'", async () => { - const calls = stubFetch(() => json(approvalFixture)); - await approveApproval("tnt_1", "apr_1"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/approvals/apr_1/approve"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - scope: "once", - }); - }); - - test("throws an ApiQueryError on a non-2xx response", async () => { - stubFetch(() => json({ error: { code: "already_resolved" } }, 409)); - await expect(approveApproval("tnt_1", "apr_1")).rejects.toBeInstanceOf(ApiQueryError); - }); -}); - -describe("rejectApproval", () => { - test("omits the message body when none is given", async () => { - const calls = stubFetch(() => json({ ...approvalFixture, status: "rejected" })); - await rejectApproval("tnt_1", "apr_1"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({}); - }); - - test("sends the message when given", async () => { - const calls = stubFetch(() => json({ ...approvalFixture, status: "rejected" })); - await rejectApproval("tnt_1", "apr_1", "Not now"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - message: "Not now", - }); - }); -}); - -describe("useAPIQuery", () => { - const PingSchema = type({ ok: "boolean" }); - - async function mountHook(path: string): Promise<{ - readonly latest: () => APIQuery<{ ok: boolean }>; - readonly root: Root; - readonly container: HTMLDivElement; - }> { - let latest: APIQuery<{ ok: boolean }> = { kind: "loading" }; - function Probe() { - latest = useAPIQuery(path, PingSchema); - return null; - } - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - return { latest: () => latest, root, container }; - } - - async function settle() { - for (let i = 0; i < 20; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - } - - test("a non-2xx response reports the error kind carrying the response status", async () => { - stubFetch(() => json({ error: { code: "not_found" } }, 404)); - const { latest, root, container } = await mountHook("/api/ping"); - await settle(); - expect(latest()).toEqual({ - kind: "error", - message: "Something went wrong. Try again.", - retry: expect.any(Function), - status: 404, - }); - root.unmount(); - container.remove(); - }); -}); diff --git a/apps/web/test/app-error-boundary.test.tsx b/apps/web/test/app-error-boundary.test.tsx deleted file mode 100644 index 5d386c9b3d..0000000000 --- a/apps/web/test/app-error-boundary.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// a render error anywhere in the tree used to leave the reader -// staring at a blank white page (no error boundary existed anywhere in -// apps/web). This pins the boundary's red/green behaviour — a throwing -// child renders the designed EmptyState, never a blank screen — and that -// a healthy tree passes straight through untouched. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import type { ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { AppErrorBoundary } from "../src/app-error-boundary"; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) act(() => root?.unmount()); - if (container !== null) container.remove(); - root = null; - container = null; -}); - -function Bomb(): never { - throw new Error("kaboom"); -} - -function render(children: ReactNode) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => root?.render({children})); - return container; -} - -describe("AppErrorBoundary", () => { - test("a healthy tree passes straight through", () => { - const el = render(

Everything is fine

); - expect(el.textContent).toContain("Everything is fine"); - }); - - test("a throwing child renders the empty state, not a blank screen", () => { - // React logs the caught error to the real console during this render; - // that's expected noise for a deliberately-throwing test component. - const originalError = console.error; - console.error = () => undefined; - let el: HTMLDivElement; - try { - el = render(); - } finally { - console.error = originalError; - } - expect(el.textContent).toContain("This screen hit a snag"); - expect(el.textContent).toContain("Reload"); - expect(el.textContent).not.toContain("Everything is fine"); - }); - - // a render crash that never reaches a sink is undiagnosable in - // production — the boundary must report through `reportError` - // (`@corbits/error-sink`) and show the refId that call returns, so a - // person hitting this can quote it back to support. - test("a caught render error surfaces a refId a person can quote", () => { - const originalError = console.error; - console.error = () => undefined; - let el: HTMLDivElement; - try { - el = render(); - } finally { - console.error = originalError; - } - expect(el.textContent).toMatch(/Reference: [0-9a-z]+-[0-9a-z]+/); - }); -}); diff --git a/apps/web/test/app-shell-setup-note-removed.test.tsx b/apps/web/test/app-shell-setup-note-removed.test.tsx deleted file mode 100644 index 1b449aab4e..0000000000 --- a/apps/web/test/app-shell-setup-note-removed.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// Regression test: the owner's first-run feedback was explicit — the -// "still setting up in the background" note is noise at the exact moment -// someone is forming a first impression, and it must never appear. This -// mounts the real shell with the legacy session flag set (as a stale -// browser tab from before the removal would have it) and asserts the -// note cannot render, proving removal rather than just a hidden default. - -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { AppShell } from "../src/shell/app-shell"; -import { BenchProvider } from "../src/bench-context"; -import { NavigationProvider } from "../src/navigation"; -import { ShellChromeProvider } from "../src/shell/shell-chrome-provider"; -import { TestQueryProvider } from "./test-query-provider"; - -const noop = () => undefined; -const realFetch = globalThis.fetch; -const realMatchMedia = window.matchMedia; - -const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" }; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const settle = () => act(() => sleep(10)); - -function stubMatchMedia(matching: Record): void { - window.matchMedia = ((media: string) => - ({ - media, - matches: matching[media] ?? false, - addEventListener: noop, - removeEventListener: noop, - }) as unknown as MediaQueryList) as typeof window.matchMedia; -} - -const emptyMemberships = () => - new Response(JSON.stringify({ data: [], nextCursor: null }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - -// A pending provisioning status is exactly the shape that used to flip the -// note visible once the legacy session flag was set. -const pendingProvisioningStatus = () => - new Response(JSON.stringify({ kind: "provisioning", setupAgentReady: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - -describe("app shell no longer shows the background setup note", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - stubMatchMedia({}); - sessionStorage.setItem("workbench.setup-in-progress", "1"); - globalThis.fetch = ((input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : String(input); - return Promise.resolve( - url.includes("provisioning-status") ? pendingProvisioningStatus() : emptyMemberships(), - ); - }) as typeof fetch; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - window.matchMedia = realMatchMedia; - sessionStorage.clear(); - }); - - test("renders no setup note text or markup, even with the legacy flag set", async () => { - await act(async () => { - root.render( - - - - - - {"Inbox"} - - - - - , - ); - }); - await settle(); - await settle(); - - expect(container.textContent).not.toContain("still setting up"); - expect(container.querySelector(".setup-progress-note")).toBeNull(); - }); -}); diff --git a/apps/web/test/auth.test.tsx b/apps/web/test/auth.test.tsx deleted file mode 100644 index 16c0d938ed..0000000000 --- a/apps/web/test/auth.test.tsx +++ /dev/null @@ -1,294 +0,0 @@ -// The auth gate, tested at our wiring: the session probe and the -// email+password calls are the only fetches the signed-out app knows how to -// make, and the signed-out tree contains the auth screen instead of any -// screen that talks to the hub. - -import { ThemeProvider } from "@corbits/react-ui"; -import { afterEach, describe, expect, test } from "bun:test"; -import { useCallback, useEffect, useState } from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { App } from "../src/app"; -import { AuthScreen } from "../src/auth-screen"; -import { SOCIAL_SIGN_IN_PROVIDERS, fetchSession, signIn, signOut, signUp } from "../src/session"; -import type { SessionState } from "../src/session"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - -function stubFetch( - respond: (path: string) => Response = () => { - throw new Error("unexpected fetch"); - }, -): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -const user = { id: "user_1", name: "ada", email: "ada@example.com" }; - -const noop = () => undefined; - -function renderApp(session: SessionState, path = "/"): string { - return renderToStaticMarkup( - - - , - ); -} - -describe("session probe", () => { - test("a null body means signed out, via the one auth endpoint", async () => { - const calls = stubFetch(() => json(null)); - const state = await fetchSession(); - expect(state).toEqual({ kind: "signed-out" }); - expect(calls.map((call) => call.path)).toEqual(["/api/auth/get-session"]); - }); - - test("a session body means signed in", async () => { - stubFetch(() => json({ session: { id: "sess_1" }, user })); - const state = await fetchSession(); - expect(state).toEqual({ kind: "signed-in", user }); - }); - - test("a hub failure is an error state, not a sign-out", async () => { - stubFetch(() => json({ message: "boom" }, 500)); - const state = await fetchSession(); - expect(state.kind).toBe("error"); - }); - - // a session body that parses as JSON but carries no usable - // `user` (the shape a hub restarted against an empty DB, or a cookie - // for a since-deleted user, can answer with) must still land on login - // — never the "connection lost" error screen, which would strand the - // app in a broken half-state instead of the auth screen. - test("a session body with no user means signed out, not a connectivity error", async () => { - stubFetch(() => json({ session: { id: "sess_1" } })); - const state = await fetchSession(); - expect(state).toEqual({ kind: "signed-out" }); - }); -}); - -/** Mirrors `main.tsx`'s `Root`'s own probe-on-mount wiring, minus - * provisioning — the minimum needed to prove a real DOM mount, driven by - * the real `fetchSession`, never renders the shell for an invalid session. - * `/login` is a real route now: an invalid session redirects - * there rather than swapping in the auth screen at whatever path was - * requested, so this mounts a real `navigate` to follow that redirect. */ -function ProbedApp() { - const [path, setPath] = useState("/"); - const [session, setSession] = useState({ kind: "loading" }); - const navigate = useCallback((to: string) => { - setPath(new URL(to, "http://localhost").pathname); - }, []); - useEffect(() => { - void fetchSession().then(setSession); - }, []); - return ( - - - - ); -} - -describe("an invalid session always lands on login", () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - afterEach(() => { - globalThis.fetch = realFetch; - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } - }); - - test("a null session probe renders the login screen, never the shell", async () => { - stubFetch(() => json(null)); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render(); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(container.querySelector('[data-testid="shell-sidebar"]')).toBeNull(); - expect(container.textContent).toContain("Welcome back"); - }); -}); - -describe("email and password calls", () => { - test("sign-in posts credentials to better-auth's endpoint", async () => { - const calls = stubFetch(() => json({ token: "t", user })); - const result = await signIn("ada@example.com", "hunter22"); - expect(result).toEqual({ ok: true, user }); - expect(calls).toHaveLength(1); - const call = calls[0]; - expect(call?.path).toBe("/api/auth/sign-in/email"); - expect(call?.init?.method).toBe("POST"); - expect(JSON.parse(String(call?.init?.body))).toEqual({ - email: "ada@example.com", - password: "hunter22", - }); - }); - - test("sign-up posts a name derived from the address", async () => { - const calls = stubFetch(() => json({ token: "t", user })); - const result = await signUp("ada@example.com", "hunter22"); - expect(result).toEqual({ ok: true, user }); - expect(calls[0]?.path).toBe("/api/auth/sign-up/email"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - name: "ada", - email: "ada@example.com", - password: "hunter22", - }); - }); - - test("a rejected sign-in surfaces the hub's message", async () => { - stubFetch(() => json({ message: "Invalid email or password" }, 401)); - const result = await signIn("ada@example.com", "wrong"); - expect(result).toEqual({ - ok: false, - message: "Invalid email or password", - }); - }); - - test("sign-out posts to better-auth's endpoint", async () => { - const calls = stubFetch(() => json({ success: true })); - await signOut(); - expect(calls.map((call) => call.path)).toEqual(["/api/auth/sign-out"]); - }); -}); - -describe("the gate", () => { - test("signed out at /login renders the auth screen and fires no authenticated fetch", () => { - const calls = stubFetch(); - const markup = renderApp({ kind: "signed-out" }, "/login"); - expect(calls).toHaveLength(0); - expect(markup).toContain("Email"); - expect(markup).toContain("Password"); - expect(markup).not.toContain("aria-current"); - expect(markup).not.toContain("/api/me"); - expect(markup).not.toContain("Sign out"); - }); - - test("signed in renders the shell with the sidebar and the avatar's account menu trigger", () => { - const markup = renderApp({ kind: "signed-in", user }); - // The one sidebar plus the account affordance — a menu (weekly usage, - // Settings, feedback, Log out), not a plain link straight to settings - // (grown to the reference shape in). - expect(markup).toContain('data-testid="shell-sidebar"'); - expect(markup).toContain("shell-sidebar-account-btn"); - expect(markup).toContain('aria-label="ada · Account menu"'); - expect(markup).not.toContain("user_1"); - }); - - test("loading and error are their own screens, not a broken shell", () => { - expect(renderApp({ kind: "loading" })).toContain("Getting your workbench ready"); - const markup = renderApp({ kind: "error", message: "socket hang up" }); - expect(markup).toContain("socket hang up"); - expect(markup).toContain("Try again"); - }); -}); - -// Stock Interchange exposes no sign-in discovery endpoint, so the buttons -// come from client config: the providers this client knows how to draw. -// The hub still decides which of them actually work (better-auth only -// wires the credential pairs it was given); an unconfigured click surfaces -// better-auth's own error on the form, never a new endpoint. -describe("social sign-in buttons", () => { - test("the client config lists the providers the sign-in screen offers", () => { - expect([...SOCIAL_SIGN_IN_PROVIDERS].sort()).toEqual(["github", "google"]); - }); - - test("the auth screen draws every client-configured provider button with no fetch", () => { - const calls = stubFetch(); - const markup = renderToStaticMarkup(); - expect(calls).toHaveLength(0); - expect(markup).toContain("Continue with Google"); - expect(markup).toContain("Continue with GitHub"); - }); - - // Keeper: stock Interchange exposes no sign-in discovery endpoint, so the - // buttons always render and the hub decides which providers work — an - // unconfigured click must surface better-auth's own message on the form. - test("a failed social sign-in surfaces better-auth's message on the form", async () => { - const calls = stubFetch(() => - json({ message: "Social sign-in is not configured for this provider" }, 400), - ); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - try { - await act(async () => { - root.render(); - }); - const button = [...container.querySelectorAll("button")].find( - (element) => element.textContent === "Continue with Google", - ); - expect(button).toBeDefined(); - await act(async () => { - button?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(calls.map((call) => call.path)).toEqual(["/api/auth/sign-in/social"]); - expect(container.textContent).toContain("Social sign-in is not configured for this provider"); - } finally { - await act(async () => { - root.unmount(); - }); - container.remove(); - } - }); -}); - -describe("auth screen modes", () => { - test("sign-in leads, with creating an account as the toggle", () => { - const markup = renderToStaticMarkup(); - expect(markup).toContain("Welcome back"); - expect(markup).toContain("Create an account"); - }); -}); diff --git a/apps/web/test/bench-activity.test.tsx b/apps/web/test/bench-activity.test.tsx deleted file mode 100644 index 51f21d00cf..0000000000 --- a/apps/web/test/bench-activity.test.tsx +++ /dev/null @@ -1,252 +0,0 @@ -// `useBenchActivity` is the second column's one data source: it refetches -// on bench changes, not on route changes, and reports "empty" rather than -// fetching anything when there is no bench selected yet. Every listing it -// reads goes through the shared `tenantKeys` factories (see -// `../src/query-client.ts`), so this hook's two mounts (`WorkbenchesBand` and -// `LiveActivityBand`) share one cache rather than each firing its own -// fetch — proven end to end in `fetch-dedupe.test.tsx`. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { WORKBENCHES_MUTATED_EVENT } from "@/chat"; - -import { useBenchActivity } from "../src/shell/bench-activity"; -import type { BenchActivityQuery } from "../src/shell/bench-activity"; -import { TestQueryProvider } from "./test-query-provider"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - -function stubTenantFetch( - calls: string[], - data: { - readonly workbenches?: readonly unknown[]; - readonly chats?: readonly unknown[]; - readonly agents?: readonly unknown[]; - } = {}, -): void { - globalThis.fetch = ((input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - calls.push(path); - if (path.includes("/agent-definitions/visible")) - return Promise.resolve(json({ definitions: data.agents ?? [] })); - if (path.includes("kind=chat")) return Promise.resolve(json({ items: data.chats ?? [] })); - return Promise.resolve(json({ items: data.workbenches ?? [] })); - }) as typeof fetch; -} - -async function mountHook(tenantId: string | null): Promise<{ - readonly latest: () => BenchActivityQuery; - readonly root: Root; - readonly container: HTMLDivElement; -}> { - let latest: BenchActivityQuery = { kind: "loading" }; - function Probe({ tenantId }: { readonly tenantId: string | null }) { - latest = useBenchActivity(tenantId); - return null; - } - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - return { latest: () => latest, root, container }; -} - -async function settle() { - for (let i = 0; i < 20; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } -} - -describe("useBenchActivity", () => { - test("reports empty with no bench selected, fetching nothing", async () => { - const calls: string[] = []; - stubTenantFetch(calls); - const { latest, root, container } = await mountHook(null); - expect(latest()).toEqual({ kind: "empty" }); - expect(calls).toEqual([]); - root.unmount(); - container.remove(); - }); - - // the routines query resolves with no items and no fetch — - // the `feed=fires` route is deleted and the native listing has no - // fires equivalent — so the band renders its honest empty state. - test("fetches workbenches, chats, and agents for the selected bench; routines resolve empty", async () => { - const calls: string[] = []; - stubTenantFetch(calls); - const { latest, root, container } = await mountHook("tnt_1"); - await settle(); - expect(latest()).toEqual({ - kind: "ready", - workbenches: [], - chats: [], - agents: [], - routines: [], - }); - // Per-kind workbench fetches — the shared query key each listing surface - // subscribes to (see `tenantKeys.workbenches`). - expect(calls.some((path) => path.includes("kind=workbench"))).toBe(true); - expect(calls.some((path) => path.includes("kind=chat"))).toBe(true); - expect(calls.some((path) => path.includes("/workflows/runs"))).toBe(false); - expect(calls.some((path) => path.includes("/top-level-runs"))).toBe(false); - expect(calls.some((path) => path.includes("/agent-definitions/visible"))).toBe(true); - root.unmount(); - container.remove(); - }); - - test("splits workbenches by kind; routines stay empty without fetching", async () => { - const calls: string[] = []; - stubTenantFetch(calls, { - workbenches: [ - { - id: "run_host1", - title: "General", - kind: "workbench", - pinned: true, - participants: [{ address: "run_invited1@tnt1.example", handle: "echo" }], - }, - ], - chats: [ - { - id: "run_chat1", - title: "echo", - kind: "chat", - pinned: false, - participants: [], - }, - ], - }); - const { latest, root, container } = await mountHook("tnt_1"); - await settle(); - const state = latest(); - if (state.kind !== "ready") throw new Error(`not ready: ${state.kind}`); - expect(state.workbenches.map((c) => c.id)).toEqual(["run_host1"]); - expect(state.chats.map((c) => c.id)).toEqual(["run_chat1"]); - expect(state.routines).toEqual([]); - expect(calls.some((path) => path.includes("/top-level-runs"))).toBe(false); - root.unmount(); - container.remove(); - }); - - test("surfaces every visible agent definition, own and inherited", async () => { - const calls: string[] = []; - stubTenantFetch(calls, { - agents: [ - { - id: "wfd_outreach", - name: "Outreach", - tenantId: "tnt_ancestor", - tenantName: "Acme", - createdAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - const { latest, root, container } = await mountHook("tnt_1"); - await settle(); - const state = latest(); - if (state.kind !== "ready") throw new Error(`not ready: ${state.kind}`); - expect(state.agents).toEqual([ - { - id: "wfd_outreach", - name: "Outreach", - tenantId: "tnt_ancestor", - tenantName: "Acme", - createdAt: "2026-01-01T00:00:00.000Z", - }, - ]); - root.unmount(); - container.remove(); - }); - - // a workbench minted anywhere (picker, agent launch, land-hop) - // must show up in the sidebar the moment `createWorkbench` resolves — no - // waiting for the next unrelated refetch. `WORKBENCHES_MUTATED_EVENT` is - // the one signal every create path shares; this proves the listener - // actually invalidates the cached listing and the new row appears, - // rather than just proving the event fires (see - // `packages/chat-ui/test/workbenches-mutated-event.test.ts` for that). - test("a WORKBENCHES_MUTATED_EVENT for this tenant refetches and surfaces the new workbench immediately", async () => { - const calls: string[] = []; - let workbenches: readonly unknown[] = []; - stubTenantFetch(calls, { - get workbenches() { - return workbenches; - }, - }); - const { latest, root, container } = await mountHook("tnt_1"); - await settle(); - expect((latest() as { workbenches?: readonly unknown[] }).workbenches).toEqual([]); - - // The row a fresh `createWorkbench` call would have returned — present - // in the backing store now, as if the mint had just landed. - workbenches = [ - { - id: "run_fresh1", - title: "New Workbench", - kind: "workbench", - pinned: true, - participants: [], - }, - ]; - - await act(async () => { - window.dispatchEvent( - new CustomEvent(WORKBENCHES_MUTATED_EVENT, { - detail: { tenantId: "tnt_1" }, - }), - ); - }); - await settle(); - - const state = latest(); - if (state.kind !== "ready") throw new Error(`not ready: ${state.kind}`); - expect(state.workbenches.map((w) => w.id)).toEqual(["run_fresh1"]); - - root.unmount(); - container.remove(); - }); - - test("a WORKBENCHES_MUTATED_EVENT for a different tenant does not trigger a refetch here", async () => { - const calls: string[] = []; - stubTenantFetch(calls); - const { latest, root, container } = await mountHook("tnt_1"); - await settle(); - const callsBeforeEvent = calls.length; - - await act(async () => { - window.dispatchEvent( - new CustomEvent(WORKBENCHES_MUTATED_EVENT, { - detail: { tenantId: "tnt_other" }, - }), - ); - }); - await settle(); - - expect(calls.length).toBe(callsBeforeEvent); - const state = latest(); - if (state.kind !== "ready") throw new Error(`not ready: ${state.kind}`); - expect(state.workbenches).toEqual([]); - - root.unmount(); - container.remove(); - }); -}); diff --git a/apps/web/test/canvas-column.test.tsx b/apps/web/test/canvas-column.test.tsx deleted file mode 100644 index 61e78b2331..0000000000 --- a/apps/web/test/canvas-column.test.tsx +++ /dev/null @@ -1,324 +0,0 @@ -// The canvas profile card's Message action: no silent no-op when there is -// no bench to message against, and the panel never closes ahead of the DM -// actually resolving — matching the toast-on-unable pattern the sibling -// Mention action already used. - -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -import { spyOnReactUiToast } from "./react-ui-toast-mock"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -const toastMock = spyOnReactUiToast(); - -let ensureProfileDmResult: Promise< - { kind: "ready"; workbenchId: string } | { kind: "error"; message: string } -> = Promise.resolve({ kind: "ready", workbenchId: "chn_dm" }); - -mock.module("../src/profile-relations", () => ({ - ensureProfileDm: mock(() => ensureProfileDmResult), - loadSharedWorkbenches: mock(() => Promise.resolve([])), -})); - -const { BenchProvider } = await import("../src/bench-context"); -const { NavigationProvider } = await import("../src/navigation"); -const { CanvasColumn } = await import("../src/shell/canvas-column"); -const { TestQueryProvider } = await import("./test-query-provider"); - -const noop = () => undefined; -const realFetch = globalThis.fetch; - -const membership = { - principalId: "prn_1", - tenantId: "tnt_1", - tenantName: "Test Bench", - tenantSlug: "test-bench", - kind: "user", - status: "active", - roles: [], -}; - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function routeFetch(input: RequestInfo | URL): Promise { - const url = String(input); - if (url.includes("/api/me/principals")) { - return Promise.resolve(jsonResponse({ data: [membership], nextCursor: null })); - } - return Promise.reject(new Error(`unrouted fetch in canvas-column test: ${url}`)); -} - -const profile = { - kind: "member" as const, - address: "user:ada", - handle: "ada", - displayName: "Ada Lovelace", - initials: "AL", -}; - -describe("canvas profile card Message action", () => { - let container: HTMLDivElement; - let root: Root; - let navigated: string[]; - let closed: boolean; - - beforeEach(() => { - globalThis.fetch = routeFetch as typeof fetch; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - navigated = []; - closed = false; - toastMock.mockClear(); - ensureProfileDmResult = Promise.resolve({ - kind: "ready", - workbenchId: "chn_dm", - }); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - window.localStorage.clear(); - }); - - async function renderPanel(): Promise { - await act(async () => { - root.render( - - navigated.push(to)}> - - { - closed = true; - }} - onToggleFocus={noop} - onNavigate={(to) => navigated.push(to)} - /> - - - , - ); - }); - // Let BenchProvider's membership fetch actually settle before - // interacting — a button exists from the very first render regardless, - // so presence alone can't gate this. - for (let i = 0; i < 20; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - } - - function messageButton(): HTMLButtonElement { - // "Message" and its pending state "Messaging…" share the "Messag" stem — - // matching that (rather than "Message" itself) covers both without also - // catching "Mention". - const button = Array.from(container.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.startsWith("Messag") === true, - ); - if (button === undefined) throw new Error("Message button not found"); - return button; - } - - test("stays open and shows a pending label while the DM is being resolved, then closes and navigates once it's ready", async () => { - let resolveDm: (value: { kind: "ready"; workbenchId: string }) => void = () => undefined; - ensureProfileDmResult = new Promise((resolve) => { - resolveDm = resolve; - }); - await renderPanel(); - - await act(async () => { - messageButton().click(); - }); - - expect(closed).toBe(false); - expect(messageButton().textContent).toBe("Messaging…"); - - await act(async () => { - resolveDm({ kind: "ready", workbenchId: "chn_dm" }); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(navigated).toContain("/w/chn_dm"); - expect(closed).toBe(true); - }); - - test("surfaces a toast and keeps the panel open instead of closing when the DM can't be created", async () => { - ensureProfileDmResult = Promise.resolve({ - kind: "error", - message: "No running agent found for @ada.", - }); - await renderPanel(); - - await act(async () => { - messageButton().click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(toastMock).toHaveBeenCalledWith("No running agent found for @ada."); - expect(closed).toBe(false); - expect(navigated).toEqual([]); - }); - - test("with no bench resolved yet, Message toasts honestly instead of silently closing", async () => { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.reject(new Error("no bench resolves in this test"))) as typeof fetch; - await renderPanel(); - - await act(async () => { - messageButton().click(); - }); - - expect(toastMock).toHaveBeenCalledWith("Open a workbench to message @ada"); - expect(closed).toBe(false); - expect(navigated).toEqual([]); - }); -}); - -describe("canvas artifact pane: single-user text editing", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - globalThis.fetch = routeFetch as typeof fetch; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - window.localStorage.clear(); - }); - - async function renderArtifact(props: { - readonly artifact: { - readonly id: string; - readonly title: string; - readonly rendererKind: "doc" | "sheet" | "pdf" | "unsupported"; - readonly content: string; - readonly canEdit?: boolean; - }; - readonly onSaveArtifact?: (content: string) => void; - }): Promise { - await act(async () => { - root.render( - - - - - - - , - ); - }); - } - - test("a non-'doc' kind never renders the text editor, even with canEdit", async () => { - await renderArtifact({ - artifact: { - id: "art_1", - title: "Numbers", - rendererKind: "sheet", - content: "a,b\n1,2", - canEdit: true, - }, - }); - - expect(container.querySelector("textarea")).toBeNull(); - }); - - test("a 'doc' artifact with canEdit renders an editable textarea seeded with its content", async () => { - await renderArtifact({ - artifact: { - id: "art_2", - title: "Notes", - rendererKind: "doc", - content: "fetched draft", - canEdit: true, - }, - }); - - const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe("fetched draft"); - expect(textarea?.hasAttribute("readonly")).toBe(false); - }); - - test("a 'doc' artifact without canEdit renders the same textarea, but readonly", async () => { - await renderArtifact({ - artifact: { - id: "art_3", - title: "Notes", - rendererKind: "doc", - content: "fetched content", - canEdit: false, - }, - }); - - const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe("fetched content"); - expect(textarea?.hasAttribute("readonly")).toBe(true); - }); - - test("typing in the editable textarea eventually calls onSaveArtifact with the full text", async () => { - const saved: string[] = []; - await renderArtifact({ - artifact: { - id: "art_4", - title: "Notes", - rendererKind: "doc", - content: "hello", - canEdit: true, - }, - onSaveArtifact: (content) => saved.push(content), - }); - - const textarea = container.querySelector("textarea"); - if (textarea === null) throw new Error("textarea not found"); - - await act(async () => { - const nativeSetter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )?.set; - nativeSetter?.call(textarea, "hello world"); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1_100)); - }); - - expect(saved).toEqual(["hello world"]); - }); -}); diff --git a/apps/web/test/chat-header-toggle.test.tsx b/apps/web/test/chat-header-toggle.test.tsx deleted file mode 100644 index fd2bb69039..0000000000 --- a/apps/web/test/chat-header-toggle.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// ChatWorkspace carries the shell's single col2 toggle through its -// headerLeading slot: first in the workbench header when a workbench is active, -// and in a bare header while workbenches load — so chat never strands the -// toggle behind its own loading states. Lives here (not packages/chat-ui) -// because these branches only resolve under a DOM with effects. - -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { ChatWorkspace } from "@/chat"; - -import { TestQueryProvider } from "./test-query-provider"; - -const realFetch = globalThis.fetch; -const realEventSource = globalThis.EventSource; - -class StubEventSource { - addEventListener(): void {} - removeEventListener(): void {} - close(): void {} -} - -const workbenchWire = { - id: "ch_1", - title: "Growth", - kind: "workbench", - pinned: false, - participants: [], -}; - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function routeFetch(input: RequestInfo | URL): Promise { - const url = String(input); - if (url.includes("/chat/workbenches?kind=workbench")) { - return Promise.resolve(jsonResponse({ items: [workbenchWire] })); - } - if (url.includes("/chat/workbenches?kind=chat")) { - return Promise.resolve(jsonResponse({ items: [] })); - } - if (url.includes("/threads")) { - return Promise.resolve( - jsonResponse({ - rootThreadId: "th_1", - items: [ - { - id: "th_1", - kind: "root", - parentMessageId: null, - runRef: null, - title: null, - createdAt: "2026-01-15T12:00:00.000Z", - }, - ], - }), - ); - } - if (url.includes("/messages")) { - return Promise.resolve(jsonResponse({ items: [] })); - } - return Promise.resolve(jsonResponse({})); -} - -const leading =