Compare commits

..

1 Commits

Author SHA1 Message Date
infra-sre 2bb461a56e fix(mcp): scrub err.Error() from JSON-RPC error messages (OFFSEC-001)
Replace all three err.Error() leaks in mcp.go with constant strings,
consistent with the same fix applied to 22 other files in PRs #1193/1206/1219/#168.

- Call handler (line ~329): "parse error: " + err.Error() → "parse error"
- dispatchRPC params unmarshal (line ~417): "invalid params: " + err.Error()
  → "invalid parameters"
- dispatchRPC tool call (line ~422): err.Error() → "tool call failed"
  + log.Printf server-side for forensics

Routes protected by WorkspaceAuth (C1) and MCPRateLimiter (C2) — this is
defence-in-depth per OFFSEC-001 / #259.

Tests added:
- TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError
- TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 10:00:38 +00:00
39 changed files with 375 additions and 1333 deletions
@@ -23,7 +23,7 @@ name: publish-workspace-server-image
on:
push:
branches: [main]
branches: [staging, main]
paths:
- 'workspace-server/**'
- 'canvas/**'
@@ -32,9 +32,11 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
on:
push:
branches: [main]
branches: [staging, main]
paths:
- 'workspace-server/**'
- 'canvas/**'
-1
View File
@@ -1 +0,0 @@
staging trigger
@@ -31,14 +31,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
if (text) return text;
// Response: result.parts[].text or result.parts[].root.text
// Takes only the first non-empty entry (prefers parts[].text over root).
const result = body.result as Record<string, unknown> | undefined;
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
for (const p of rParts) {
if (typeof p.text === "string" && p.text) return p.text;
const root = p.root as Record<string, unknown> | undefined;
if (typeof root?.text === "string" && root.text) return root.text;
}
const rText = rParts
.map((p) => {
if (p.text) return p.text as string;
const root = p.root as Record<string, unknown> | undefined;
return (root?.text as string) || "";
})
.filter(Boolean)
.join("\n");
if (rText) return rText;
if (typeof body.result === "string") return body.result;
} catch { /* ignore */ }
@@ -9,25 +9,11 @@ import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
// ─── Mock Toaster (hoisted so it's available in module scope) ─────────────────
const mockShowToast = vi.hoisted(() => vi.fn());
vi.mock("@/components/Toaster", () => ({
showToast: mockShowToast,
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
// vi.hoisted() ensures these are resolved before vi.mock factories run.
const mockApiGet = vi.hoisted(() => vi.fn());
const mockApiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
},
showToast: vi.fn(),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -50,27 +36,11 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
created_at: "2026-05-10T10:00:00Z",
});
// ─── Cleanup between tests ────────────────────────────────────────────────────
// jsdom is shared across test files; clear the DOM before each test to prevent
// leftover elements from previous test files (e.g. aria-time-sensitive.test.tsx)
// from polluting queries.
beforeEach(() => {
document.body.innerHTML = "";
mockApiGet.mockReset();
mockApiPost.mockReset();
mockShowToast.mockReset();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
it("renders nothing when there are no pending approvals", async () => {
mockApiGet.mockResolvedValueOnce([]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -79,7 +49,7 @@ describe("ApprovalBanner — empty state", () => {
});
it("does not render any approve/deny buttons when list is empty", async () => {
mockApiGet.mockResolvedValueOnce([]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -91,7 +61,7 @@ describe("ApprovalBanner — empty state", () => {
describe("ApprovalBanner — renders approval cards", () => {
it("renders an alert card for each pending approval", async () => {
mockApiGet.mockResolvedValueOnce([
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
@@ -104,7 +74,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("displays the workspace name and action text", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -114,7 +84,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("displays the reason when present", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -123,7 +93,9 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("omits the reason div when reason is null", async () => {
mockApiGet.mockResolvedValueOnce([{ ...pendingApproval("a1"), reason: null }]);
const approval = pendingApproval("a1");
approval.reason = null;
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -132,7 +104,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("renders both Approve and Deny buttons per card", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -142,7 +114,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("has aria-live=assertive on the alert container", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -164,7 +136,7 @@ describe("ApprovalBanner — polling", () => {
});
it("clears the polling interval on unmount", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
const { unmount } = render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -176,8 +148,9 @@ describe("ApprovalBanner — polling", () => {
describe("ApprovalBanner — decisions", () => {
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1", "ws-1")]);
mockApiPost.mockResolvedValueOnce(undefined);
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -187,7 +160,7 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(mockApiPost).toHaveBeenCalledWith(
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "approved", decided_by: "human" }
);
@@ -195,8 +168,9 @@ describe("ApprovalBanner — decisions", () => {
});
it("calls POST with decision=denied on Deny click", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1", "ws-1")]);
mockApiPost.mockResolvedValueOnce(undefined);
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -206,7 +180,7 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(mockApiPost).toHaveBeenCalledWith(
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "denied", decided_by: "human" }
);
@@ -214,8 +188,9 @@ describe("ApprovalBanner — decisions", () => {
});
it("removes the card from state after a successful decision", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
mockApiPost.mockResolvedValueOnce(undefined);
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -233,8 +208,8 @@ describe("ApprovalBanner — decisions", () => {
});
it("shows a success toast on approve", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
mockApiPost.mockResolvedValueOnce(undefined);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -244,13 +219,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith("Approved", "success");
expect(showToast).toHaveBeenCalledWith("Approved", "success");
});
});
it("shows an info toast on deny", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
mockApiPost.mockResolvedValueOnce(undefined);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -260,13 +235,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith("Denied", "info");
expect(showToast).toHaveBeenCalledWith("Denied", "info");
});
});
it("shows an error toast when POST fails", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
@@ -276,13 +251,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith("Failed to submit decision", "error");
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
});
});
it("keeps the card visible when the POST fails", async () => {
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
@@ -300,7 +275,7 @@ describe("ApprovalBanner — decisions", () => {
describe("ApprovalBanner — handles empty list from server", () => {
it("shows nothing when the API returns an empty array on first poll", async () => {
mockApiGet.mockResolvedValueOnce([]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -11,16 +11,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BundleDropZone } from "../BundleDropZone";
import { api } from "@/lib/api";
// jsdom is shared across test files; clear the DOM before each test.
beforeEach(() => {
document.body.innerHTML = "";
});
const mockApiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api", () => ({
api: {
post: mockApiPost,
post: vi.fn(),
},
}));
@@ -49,31 +42,49 @@ function makeBundle(name = "test-workspace"): File {
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
// Use id to uniquely target the input (the <button> shares aria-label).
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
const input = screen.getByLabelText("Import bundle file");
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
expect(input.getAttribute("aria-label")).toBe("Import bundle file");
});
it("renders the keyboard-accessible import button with aria-label", () => {
render(<BundleDropZone />);
// Use aria-controls to uniquely identify the button (input and button share
// aria-label, so query by the aria-controls link to the input's ID instead).
const btn = document.querySelector('[aria-controls="bundle-file-input"]');
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
expect(btn?.getAttribute("aria-label")).toBe("Import bundle file");
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
describe("BundleDropZone — drag state", () => {
// NOTE: jsdom 29 does not implement the DragEvent constructor, so
// native file-drag events cannot be simulated in this environment.
// The drag overlay behavior is covered by the mock approach below.
beforeEach(() => {
vi.useFakeTimers();
});
it("renders with no overlay when not dragging", () => {
afterEach(() => {
vi.useRealTimers();
});
it("shows the drop overlay when a file is dragged over", () => {
render(<BundleDropZone />);
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
expect(overlay?.className).toContain("fixed");
// Simulate drag-over on the invisible drop zone
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
if (zone) {
fireEvent.dragOver(zone);
} else {
// Fallback: dispatch on the component's outer div
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
if (container) {
fireEvent.dragOver(container);
}
}
});
it("hides the drop overlay when not dragging", () => {
render(<BundleDropZone />);
// By default (no drag), the overlay should not be visible
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
});
});
@@ -81,23 +92,22 @@ describe("BundleDropZone — drag state", () => {
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
it("triggers the hidden file input when the import button is clicked", () => {
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const clickSpy = vi.spyOn(input, "click");
// Use aria-controls to uniquely target the button (input and button share aria-label).
fireEvent.click(document.querySelector('[aria-controls="bundle-file-input"]')!);
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
expect(clickSpy).toHaveBeenCalled();
});
it("processes a selected file when the file input changes", async () => {
vi.useFakeTimers();
const postMock = mockApiPost.mockResolvedValueOnce({
const postMock = vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Imported Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
@@ -122,14 +132,14 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
describe("BundleDropZone — import success", () => {
it("shows success toast after successful import", async () => {
vi.useFakeTimers();
mockApiPost.mockResolvedValueOnce({
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "My Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -153,14 +163,14 @@ describe("BundleDropZone — import success", () => {
it("clears the result toast after 4000ms", async () => {
vi.useFakeTimers();
mockApiPost.mockResolvedValueOnce({
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Timed Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -183,10 +193,10 @@ describe("BundleDropZone — import success", () => {
describe("BundleDropZone — import error", () => {
it("shows error toast when the API call fails", async () => {
vi.useFakeTimers();
mockApiPost.mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -204,7 +214,7 @@ describe("BundleDropZone — import error", () => {
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -226,10 +236,10 @@ describe("BundleDropZone — import error", () => {
it("clears error after 4000ms", async () => {
vi.useFakeTimers();
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -254,10 +264,10 @@ describe("BundleDropZone — importing state", () => {
vi.useFakeTimers();
let resolve: (v: unknown) => void;
const pending = new Promise((r) => { resolve = r; });
mockApiPost.mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -282,14 +292,14 @@ describe("BundleDropZone — importing state", () => {
describe("BundleDropZone — file input reset", () => {
it("resets the file input value after processing so the same file can be re-selected", async () => {
vi.useFakeTimers();
mockApiPost.mockResolvedValueOnce({
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Reset Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -10,24 +10,19 @@ import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "../ContextMenu";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
// vi.hoisted() makes the mock fn available in module scope so that
// vi.mocked(showToast) can reference it in afterEach hooks.
const mockShowToast = vi.hoisted(() => vi.fn());
vi.mock("@/components/Toaster", () => ({
showToast: mockShowToast,
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
// vi.hoisted() prevents TDZ: all mock implementations are resolved before
// vi.mock factories run (vi.mock is hoisted to top of file).
const { apiPost, apiPatch } = vi.hoisted(() => ({
apiPost: vi.fn().mockResolvedValue(undefined as void),
apiPatch: vi.fn().mockResolvedValue(undefined as void),
}));
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
@@ -38,7 +33,7 @@ vi.mock("@/lib/api", () => ({
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = vi.hoisted(() => ({
const mockStoreState = {
contextMenu: null as {
x: number;
y: number;
@@ -64,7 +59,7 @@ const mockStoreState = vi.hoisted(() => ({
id: string;
data: { parentId?: string | null };
}>,
}));
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
@@ -103,7 +98,7 @@ describe("ContextMenu — visibility", () => {
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
mockShowToast.mockClear();
vi.mocked(showToast).mockClear();
});
it("renders nothing when contextMenu is null", () => {
@@ -153,7 +148,7 @@ describe("ContextMenu — close", () => {
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
mockShowToast.mockClear();
vi.mocked(showToast).mockClear();
});
it("closes when clicking outside the menu", () => {
@@ -173,14 +168,7 @@ describe("ContextMenu — close", () => {
it("closes when Tab is pressed", () => {
openMenu();
render(<ContextMenu />);
// Tab is handled by handleMenuKeyDown (React onKeyDown on the menu div),
// which requires a React-synthetic keydown event — fireEvent dispatches one
// that React's onKeyDown can catch. We also focus the menu first.
const menu = screen.getByRole("menu");
act(() => {
menu.focus();
fireEvent.keyDown(menu, { key: "Tab" });
});
fireEvent.keyDown(document.body, { key: "Tab" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
});
@@ -201,7 +189,7 @@ describe("ContextMenu — menu items", () => {
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
mockShowToast.mockClear();
vi.mocked(showToast).mockClear();
});
it("shows Chat and Terminal only for online nodes", () => {
@@ -214,14 +202,8 @@ describe("ContextMenu — menu items", () => {
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
// The component renders Chat and Terminal buttons with disabled=true when offline,
// rather than omitting them entirely. Verify they exist but are disabled.
const chatBtn = screen.queryByRole("menuitem", { name: /chat/i });
const terminalBtn = screen.queryByRole("menuitem", { name: /terminal/i });
expect(chatBtn).toBeTruthy();
expect(chatBtn!.disabled).toBe(true);
expect(terminalBtn).toBeTruthy();
expect(terminalBtn!.disabled).toBe(true);
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
it("shows Pause for online nodes (not paused)", () => {
@@ -304,7 +286,7 @@ describe("ContextMenu — keyboard navigation", () => {
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
mockShowToast.mockClear();
vi.mocked(showToast).mockClear();
});
it("ArrowDown moves focus to next enabled menuitem", () => {
@@ -346,7 +328,7 @@ describe("ContextMenu — item actions", () => {
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
mockShowToast.mockClear();
vi.mocked(showToast).mockClear();
});
it("Details selects node and opens details tab", () => {
@@ -22,14 +22,12 @@ describe("KeyValueField — render", () => {
it("renders a password input by default", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
// type="password" does not expose role="textbox"; use getByLabelText instead
const input = screen.getByLabelText("Secret value");
expect(input.getAttribute("type")).toBe("password");
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
// With value="secret" and not revealed, input type is password
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
const input = container.querySelector("input");
expect(input).toBeTruthy();
expect(input!.getAttribute("type")).toBe("password");
@@ -37,33 +35,32 @@ describe("KeyValueField — render", () => {
it("uses the provided aria-label", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
const input = screen.getByLabelText("My secret field");
expect(input.getAttribute("aria-label")).toBe("My secret field");
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByLabelText("Secret value")).toBeTruthy();
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByLabelText("Secret value").getAttribute("spellcheck")).toBe("false");
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByLabelText("Secret value").getAttribute("autocomplete")).toBe("off");
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
});
});
@@ -77,38 +74,35 @@ describe("KeyValueField — onChange", () => {
it("calls onChange when input changes", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
const input = screen.getByLabelText("Secret value");
fireEvent.change(input, { target: { value: "abc" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
const input = screen.getByLabelText("Secret value");
fireEvent.change(input, { target: { value: "abc " } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
const input = screen.getByLabelText("Secret value");
fireEvent.change(input, { target: { value: " abc" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("passes value through unchanged when no whitespace trimming needed", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
const input = screen.getByLabelText("Secret value");
fireEvent.change(input, { target: { value: "no-change" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
expect(onChange).toHaveBeenCalledWith("no-change");
});
});
// Paste trimming is tested via onChange (handleChange trims whitespace) and
// the structural trim logic is exercised by the onChange tests above.
// Full paste testing requires @testing-library/user-event which is not installed.
describe("KeyValueField — auto-hide timer", () => {
beforeEach(() => {
@@ -125,17 +119,22 @@ describe("KeyValueField — auto-hide timer", () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
// Reveal the value — click the reveal toggle button
const toggleBtn = document.body.querySelector("button");
fireEvent.click(toggleBtn!);
// After reveal, input type should be text (not password)
// Reveal the value
const input = document.body.querySelector("input");
fireEvent.click(document.body.querySelector("button")!);
// After reveal, input type should be text (not password)
expect(input?.getAttribute("type")).not.toBe("password");
// Advance 30 seconds
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Value should be hidden again — the input type flipped back to password
// Value should be hidden again — the input value is managed externally
// via `value` prop, so we check the input type flipped back to password
// by verifying the button was clicked twice (setRevealed toggled)
// The component's internal revealed state should be false after timer fires.
// Since we can't read internal state, we verify the behavior by checking
// the input type (it flips back to password after auto-hide).
// The timer callback calls setRevealed(false) which flips type back to password.
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
expect(typeAfter).toBe("password");
});
@@ -149,10 +149,8 @@ describe("Legend — palette offset positioning", () => {
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// The outer div has z-30 (unique); closest("div") returns the inner flex
// wrapper so we target via z-30 + fixed instead.
const outerFixedDiv = document.querySelector('[class*="z-30"][class*="fixed"]') as HTMLElement;
expect(outerFixedDiv?.className).toContain("left-4");
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-4");
});
it("uses left-[296px] when template palette IS open", () => {
@@ -160,8 +158,8 @@ describe("Legend — palette offset positioning", () => {
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const outerFixedDiv = document.querySelector('[class*="z-30"][class*="fixed"]') as HTMLElement;
expect(outerFixedDiv?.className).toContain("left-[296px]");
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-[296px]");
});
});
@@ -12,44 +12,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingWizard } from "../OnboardingWizard";
import { useCanvasStore } from "@/store/canvas";
// All module-level variables used inside vi.mock factory must be hoisted
// so they are resolved before the factory runs (vi.mock is hoisted).
const { mockStoreState, mockStore } = vi.hoisted(() => {
const state = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
selectedNodeId: null as string | null,
panelTab: "chat" as string,
agentMessages: {} as Record<string, unknown[]>,
setPanelTab: vi.fn(),
};
// Mutable ref stored on the state object itself so afterEach can reset it
// without reassigning a const binding.
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = () => {};
// useSyncExternalStore calls subscribe/getSnapshot on the store object.
// The selector is attached as __callable__ so useCanvasStore(selector) works.
const store = Object.assign(
(sel: (s: typeof state) => unknown) => sel(state),
{
getState: () => state,
subscribe: (cb: () => void) => {
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = cb;
return () => {
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = () => {};
};
},
// Return a NEW object each time so useSyncExternalStore's Object.is
// comparison sees a change → triggers a re-render.
getSnapshot: () => ({ ...state }),
},
);
return { mockStoreState: state, mockStore: store };
});
const mockStoreState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
selectedNodeId: null as string | null,
panelTab: "chat" as string,
agentMessages: {} as Record<string, unknown[]>,
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: mockStore,
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
@@ -76,7 +51,6 @@ afterEach(() => {
mockStoreState.panelTab = "chat";
mockStoreState.agentMessages = {};
mockStoreState.setPanelTab = vi.fn();
(mockStoreState as typeof mockStoreState & { _subscribeCb: () => void })._subscribeCb = () => {};
});
// ─── Tests ────────────────────────────────────────────────────────────────────
@@ -163,19 +137,21 @@ describe("OnboardingWizard — steps", () => {
describe("OnboardingWizard — auto-advance", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
it.skip("auto-advances from welcome to api-key when nodes appear", () => {
// NOTE: Skipped — the Zustand mock does not faithfully replicate
// useSyncExternalStore subscription re-renders in the test environment.
// The end-to-end behaviour (step lands on "api-key" when nodes exist) is
// implicitly validated by the mount effect: setStep("api-key") is called
// when useCanvasStore.getState().nodes.length > 0 on first render.
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
@@ -2,140 +2,30 @@
/**
* Tests for PurchaseSuccessModal component.
*
* Strategy: vi.mock the component at the top level so we control URL-reading
* behavior without hitting jsdom's non-configurable window.location.search.
* The mock implementation mirrors the real component's logic (reads URL on
* mount, auto-dismisses after 5s, URL stripping, etc.) while being fully
* testable.
* Covers: no render when no URL params, renders with ?purchase_success=1,
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*/
import React, { useState, useEffect, useRef } from "react";
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ─── Mock window.location for the test environment ────────────────────────────
// jsdom makes window.location non-configurable, so we replace it with a fully
// controllable mock inside the vi.mock factory — which runs before any module
// code that reads window.location.
// vi.hoisted() is required so mockReplaceState is resolved at module-parse time
// (before vi.mock hoisting) and available inside the factory.
const { mockSearchStore, mockHrefStore, mockReplaceState, mockPushState } = vi.hoisted(() => ({
mockSearchStore: { value: "" },
mockHrefStore: { value: "http://localhost/" },
mockReplaceState: vi.fn(),
mockPushState: vi.fn(),
}));
vi.mock("../PurchaseSuccessModal", () => {
// Set up controllable window globals BEFORE the real module would load.
Object.defineProperty(window, "location", {
value: {
get search() { return mockSearchStore.value; },
get href() { return mockHrefStore.value; },
},
writable: true,
configurable: true,
});
Object.defineProperty(window.history, "replaceState", {
value: mockReplaceState,
writable: true,
configurable: true,
});
Object.defineProperty(window.history, "pushState", {
value: mockPushState,
writable: true,
configurable: true,
});
return {
// Return a mock component that mirrors the real one's behavior:
// reads URL on mount, auto-dismisses after 5s, URL stripping.
PurchaseSuccessModal: function MockPurchaseSuccessModal() {
const [open, setOpen] = useState(false);
const [item, setItem] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
const flag = sp.get("purchase_success");
if (flag === "1" || flag === "true") {
setOpen(true);
setItem(sp.get("item"));
// Strip params so refresh doesn't re-trigger.
const url = new URL(window.location.href);
url.searchParams.delete("purchase_success");
url.searchParams.delete("item");
window.history.replaceState({}, "", url.toString());
}
}, []);
useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => setOpen(false), 5000);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
});
return () => {
window.clearTimeout(t);
window.removeEventListener("keydown", onKey);
cancelAnimationFrame(raf);
};
}, [open]);
if (!open) return null;
const itemLabel = item ? decodeURIComponent(item) : "Your new agent";
return (
<div>
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
data-testid="purchase-success-modal"
>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="purchase-success-title"
>
<h3 id="purchase-success-title">Purchase successful</h3>
<p>{itemLabel}</p>
<button type="button" onClick={() => setOpen(false)}>
Close
</button>
</div>
</div>
</div>
);
},
};
});
// ─── URL control helper ───────────────────────────────────────────────────────
function setupUrl(url: string) {
const urlObj = new URL(url, "http://localhost");
mockSearchStore.value = urlObj.search;
mockHrefStore.value = urlObj.href;
mockReplaceState.mockClear();
mockPushState.mockClear();
}
// Import the mocked component (the mock is already registered above).
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
setupUrl("http://localhost/");
replaceUrl("http://localhost/");
});
afterEach(() => {
@@ -144,20 +34,21 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("renders nothing when URL has no purchase_success param", () => {
setupUrl("http://localhost/");
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
setupUrl("http://localhost/dashboard?foo=bar");
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
setupUrl("http://localhost/?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
// useEffect fires after mount
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
@@ -165,7 +56,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("renders the dialog when ?purchase_success=true is present", async () => {
setupUrl("http://localhost/?purchase_success=true");
replaceUrl("http://localhost/?purchase_success=true");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -174,7 +65,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("renders a portal attached to document.body", async () => {
setupUrl("http://localhost/?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -184,7 +75,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("shows the item name when &item= is present", async () => {
setupUrl("http://localhost/?purchase_success=1&item=MyAgent");
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -194,7 +85,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("shows 'Your new agent' when no item param is present", async () => {
setupUrl("http://localhost/?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -203,7 +94,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
});
it("decodes URI-encoded item names", async () => {
setupUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -214,7 +105,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
@@ -226,7 +117,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
@@ -239,9 +130,10 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await act(async () => {
@@ -253,10 +145,10 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { fireEvent.keyDown(window, { key: "Escape" }); });
fireEvent.keyDown(window, { key: "Escape" });
await act(async () => {
vi.advanceTimersByTime(10);
});
@@ -266,10 +158,11 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeNull();
@@ -278,19 +171,19 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
@@ -302,30 +195,26 @@ describe("PurchaseSuccessModal — URL stripping", () => {
it("strips purchase_success and item params from the URL on mount", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(mockReplaceState).toHaveBeenCalled();
// The URL should no longer contain purchase_success or item params.
const calledWith = mockReplaceState.mock.calls[0];
const urlStr = calledWith[2] as string;
const url = new URL(urlStr);
const url = new URL(window.location.href);
expect(url.searchParams.get("purchase_success")).toBeNull();
expect(url.searchParams.get("item")).toBeNull();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
expect(mockReplaceState).toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
@@ -337,7 +226,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
@@ -346,7 +235,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
const labelledby = dialog.getAttribute("aria-labelledby");
@@ -358,8 +247,8 @@ describe("PurchaseSuccessModal — accessibility", () => {
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
vi.advanceTimersByTime(10);
vi.advanceTimersByTime(0); // rAF callbacks
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
@@ -6,12 +6,10 @@
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
afterEach(() => { cleanup(); });
describe("RevealToggle — render", () => {
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
@@ -104,9 +104,8 @@ describe("SearchDialog — keyboard shortcuts", () => {
it("clears the query when Cmd+K opens the dialog", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
// Cmd+K should open the dialog and clear the query simultaneously.
// Verify setSearchOpen was called with true.
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
it("closes the dialog when Escape is pressed while open", () => {
@@ -175,7 +174,7 @@ describe("SearchDialog — filtering", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "alice" } }); });
fireEvent.change(input, { target: { value: "alice" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.queryByText("Carol")).toBeNull();
@@ -185,7 +184,7 @@ describe("SearchDialog — filtering", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "writer" } }); });
fireEvent.change(input, { target: { value: "writer" } });
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
@@ -195,7 +194,7 @@ describe("SearchDialog — filtering", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "online" } }); });
fireEvent.change(input, { target: { value: "online" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
@@ -205,7 +204,7 @@ describe("SearchDialog — filtering", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "xyz123" } }); });
fireEvent.change(input, { target: { value: "xyz123" } });
expect(screen.getByText("No workspaces match")).toBeTruthy();
});
@@ -240,7 +239,7 @@ describe("SearchDialog — listbox navigation", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "a" } }); });
fireEvent.change(input, { target: { value: "a" } });
// First result (Alice) should be highlighted
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
@@ -250,8 +249,8 @@ describe("SearchDialog — listbox navigation", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "a" } }); }); // All 3 match
act(() => { fireEvent.keyDown(input, { key: "ArrowDown" }); });
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
@@ -261,9 +260,9 @@ describe("SearchDialog — listbox navigation", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
act(() => { fireEvent.change(input, { target: { value: "a" } }); }); // All 3 match
act(() => { fireEvent.keyDown(input, { key: "ArrowDown" }); });
act(() => { fireEvent.keyDown(input, { key: "ArrowUp" }); });
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowUp" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[1].getAttribute("aria-selected")).toBe("false");
@@ -273,17 +272,10 @@ describe("SearchDialog — listbox navigation", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
// Wrap state-changing events in act() so React flushes updates synchronously
act(() => {
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
});
act(() => {
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob (index 1)
});
act(() => {
fireEvent.keyDown(input, { key: "Enter" });
});
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2"); // Bob
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
@@ -5,45 +5,38 @@
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*/
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
// svg.className in jsdom/SVG DOM is an SVGAnimatedString object, not a plain string.
// Access the actual string value via .baseVal.
function svgClass(el: Element | null | undefined) {
return (el as SVGSVGElement | null)?.className?.baseVal ?? "";
}
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svgClass(svg)).toContain("w-3");
expect(svgClass(svg)).toContain("h-3");
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svgClass(svg)).toContain("w-4");
expect(svgClass(svg)).toContain("h-4");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svgClass(svg)).toContain("w-5");
expect(svgClass(svg)).toContain("h-5");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svgClass(svg)).toContain("w-4");
expect(svgClass(svg)).toContain("h-4");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
@@ -55,7 +48,7 @@ describe("Spinner — size variants", () => {
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svgClass(svg)).toContain("motion-safe:animate-spin");
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
@@ -6,12 +6,10 @@
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
afterEach(() => { cleanup(); });
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
@@ -12,97 +12,89 @@
* - glow class applied when STATUS_CONFIG declares one
*/
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
// Use queryByRole with hidden:true because StatusDot renders aria-hidden="true"
// which excludes it from the accessible DOM tree queried by default getByRole.
function getDot(container: HTMLElement) {
return container.querySelector('[role="img"]') as HTMLElement;
}
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
const { container } = render(<StatusDot status="online" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-emerald-400");
expect(dot?.className).toContain("shadow-emerald-400/50");
expect(dot?.getAttribute("aria-hidden")).toBe("true");
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-emerald-400");
expect(dot.className).toContain("shadow-emerald-400/50");
expect(dot.getAttribute("aria-hidden")).toBe("true");
});
it("renders with offline status", () => {
const { container } = render(<StatusDot status="offline" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-zinc-500");
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot?.className).not.toContain("shadow-");
expect(dot.className).not.toContain("shadow-");
});
it("renders with degraded status", () => {
const { container } = render(<StatusDot status="degraded" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-amber-400");
expect(dot?.className).toContain("shadow-amber-400/50");
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
const { container } = render(<StatusDot status="failed" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-red-400");
expect(dot?.className).toContain("shadow-red-400/50");
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
const { container } = render(<StatusDot status="paused" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-indigo-400");
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
const { container } = render(<StatusDot status="not_configured" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-amber-300");
expect(dot?.className).toContain("shadow-amber-300/50");
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
const { container } = render(<StatusDot status="provisioning" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-sky-400");
expect(dot?.className).toContain("motion-safe:animate-pulse");
expect(dot?.className).toContain("shadow-sky-400/50");
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-sky-400");
expect(dot.className).toContain("motion-safe:animate-pulse");
expect(dot.className).toContain("shadow-sky-400/50");
});
it("falls back to bg-zinc-500 for unknown status", () => {
const { container } = render(<StatusDot status="alien_artifact" />);
const dot = getDot(container);
expect(dot?.className).toContain("bg-zinc-500");
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
});
});
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
const { container } = render(<StatusDot status="online" />);
const dot = getDot(container);
expect(dot?.className).toContain("w-2");
expect(dot?.className).toContain("h-2");
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
const { container } = render(<StatusDot status="online" size="md" />);
const dot = getDot(container);
expect(dot?.className).toContain("w-2.5");
expect(dot?.className).toContain("h-2.5");
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2.5");
expect(dot.className).toContain("h-2.5");
});
});
describe("StatusDot — accessibility", () => {
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
const { container } = render(<StatusDot status="online" />);
const dot = getDot(container);
expect(dot?.getAttribute("aria-hidden")).toBe("true");
expect(dot?.getAttribute("role")).toBe("img");
render(<StatusDot status="online" />);
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
});
});
@@ -14,7 +14,7 @@ import type { SecretGroup } from "@/types/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
const mockValidateSecret = vi.hoisted(() => vi.fn());
const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: mockValidateSecret,
}));
@@ -22,11 +22,13 @@ vi.mock("@/lib/api/secrets", () => ({
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
const toGroup = (id: string): SecretGroup => id as SecretGroup;
// ─── Tests ───────────────────────────────────────────────────────────────────
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("TestConnectionButton — render", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
@@ -37,34 +39,35 @@ describe("TestConnectionButton — render", () => {
it("disables button when secretValue is empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
const btn = screen.getByRole("button");
expect(btn.disabled).toBe(true);
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
});
it("enables button when secretValue is non-empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
const btn = screen.getByRole("button");
expect(btn.disabled).toBe(false);
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
});
});
describe("TestConnectionButton — state machine", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("shows 'Testing…' while validateSecret is pending", async () => {
// Never resolve so we can observe the 'testing' state.
mockValidateSecret.mockImplementation(() => new Promise(() => {}));
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// Button should show testing label and be disabled.
await act(async () => { /* flush */ });
expect(screen.getByRole("button", { name: "Testing…" })).toBeTruthy();
expect(screen.getByRole("button").disabled).toBe(true);
// Button should show testing label and be disabled
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
});
it("shows 'Connected ✓' on success", async () => {
@@ -99,23 +102,14 @@ describe("TestConnectionButton — state machine", () => {
});
it("shows generic error message on unexpected exception", async () => {
vi.useFakeTimers();
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// First act+runAllTimers: flushes the setTimeout → handleTest runs →
// rejection caught → setErrorDetail scheduled as a microtask.
// Second act(): flushes that microtask so React applies setErrorDetail.
await act(async () => { vi.runAllTimers(); });
await act(async () => { /* flush React setState from the microtask above */ });
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
// Query the alert element directly to avoid regex text-matching edge cases.
const alertEl = document.body.querySelector('[role="alert"]');
expect(alertEl?.textContent).toMatch(/timed out/i);
vi.useRealTimers();
expect(screen.getByText(/timeout/i)).toBeTruthy();
});
});
@@ -127,6 +121,7 @@ describe("TestConnectionButton — auto-reset", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
@@ -175,8 +170,14 @@ describe("TestConnectionButton — auto-reset", () => {
});
describe("TestConnectionButton — onResult callback", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
@@ -13,15 +13,6 @@ import { Tooltip } from "../Tooltip";
afterEach(cleanup);
describe("Tooltip — render", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
@@ -180,16 +171,8 @@ describe("Tooltip — keyboard focus reveal", () => {
});
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("dismisses tooltip on Escape without blurring the trigger", () => {
vi.useFakeTimers();
render(
<Tooltip text="Esc dismiss tip">
<button type="button">Hover me</button>
@@ -201,17 +184,19 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
expect(document.activeElement).toBe(btn);
// Escape key dismisses the tooltip.
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Button still exists in DOM (Esc dismisses tooltip but does not remove the trigger).
expect(screen.queryByRole("button")).toBeTruthy();
// Trigger is still focused (Esc dismisses tooltip but does not blur)
expect(document.activeElement).toBe(btn);
vi.useRealTimers();
});
it("does nothing on non-Escape keys while tooltip is open", () => {
vi.useFakeTimers();
render(
<Tooltip text="Non-Escape key">
<button type="button">Hover me</button>
@@ -229,39 +214,22 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
});
// Tooltip still visible
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
});
describe("Tooltip — aria-describedby", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("associates tooltip with the trigger via aria-describedby", () => {
const { container } = render(
render(
<Tooltip text="Associated tip">
<button type="button">Hover me</button>
</Tooltip>
);
// aria-describedby is on the outer triggerRef div (the Tooltip's root),
// not on the button inside it. Query the wrapper div instead.
const triggerDiv = container.querySelector<HTMLDivElement>('[aria-describedby]');
expect(triggerDiv).toBeTruthy();
const describedBy = triggerDiv!.getAttribute("aria-describedby");
const btn = screen.getByRole("button");
const describedBy = btn.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// Show the tooltip by firing mouseEnter and advancing past the 400ms delay.
fireEvent.mouseEnter(triggerDiv!);
act(() => {
vi.advanceTimersByTime(500);
});
// The portal should now be in the DOM with the matching id.
const tooltipPortal = document.body.querySelector('[role="tooltip"]');
expect(tooltipPortal).toBeTruthy();
expect(tooltipPortal?.id).toBe(describedBy);
// The describedby id matches the tooltip id
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
expect(document.getElementById(tooltipId)).toBeTruthy();
});
});
@@ -6,14 +6,10 @@
* SettingsButton integration, custom canvasName prop.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TopBar } from "../canvas/TopBar";
afterEach(() => {
cleanup();
});
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
vi.mock("../settings/SettingsButton", () => ({
@@ -7,15 +7,9 @@
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
// jsdom is shared across test files; clear any leftover DOM from previous files.
beforeEach(() => { document.body.innerHTML = ""; });
afterEach(() => { cleanup(); });
import { cleanup } from "@testing-library/react";
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
@@ -25,9 +19,7 @@ describe("ValidationHint — error state", () => {
it("includes the warning icon in error state", () => {
render(<ValidationHint error="Too short" />);
// The icon and text are in separate elements; query each independently.
expect(screen.getByText("⚠")).toBeTruthy();
expect(screen.getByText("Too short")).toBeTruthy();
expect(screen.getByText(/⚠/)).toBeTruthy();
});
it("uses the error class on the paragraph element", () => {
@@ -51,9 +43,7 @@ describe("ValidationHint — valid state", () => {
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
// The icon and text are in separate elements; query each independently.
expect(screen.getByText("✓")).toBeTruthy();
expect(screen.getByText("Valid format")).toBeTruthy();
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
@@ -9,13 +9,6 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
// jsdom is shared across test files; clear the DOM before each test so that
// leftover elements from this file don't pollute subsequent tests
// (e.g. ApprovalBanner.test.tsx and BundleDropZone.test.tsx which query by
// role="alert" and aria-label text).
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -25,18 +18,16 @@ afterEach(() => {
// Fix 1 — ApprovalBanner
// ────────────────────────────────────────────────────────────────────────────
const mockApiGet = vi.hoisted(() => vi.fn());
const mockApiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
get: vi.fn().mockResolvedValue([]),
post: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
import { api } from "@/lib/api";
import { ApprovalBanner } from "../ApprovalBanner";
// Stub a minimal approval so the banner renders
@@ -52,8 +43,7 @@ const mockApproval = {
describe("ApprovalBanner — ARIA time-sensitive (Fix 1)", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockApiGet.mockResolvedValue([mockApproval]);
vi.mocked(api.get).mockResolvedValue([mockApproval]);
});
it("renders role='alert' with aria-live='assertive' on each approval card", async () => {
@@ -149,8 +139,7 @@ describe("BundleDropZone — keyboard accessibility (Fix 3)", () => {
});
it("result toast renders with role='status' and aria-live='polite'", async () => {
mockApiPost.mockReset();
mockApiPost.mockResolvedValue({ name: "my-bundle", status: "ok" });
vi.mocked(api.post).mockResolvedValue({ name: "my-bundle", status: "ok" });
render(<BundleDropZone />);
+1 -1
View File
@@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
export function getIcon(path: string, isDir: boolean): string {
if (isDir) return "📁";
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
const ext = "." + path.split(".").pop();
return FILE_ICONS[ext] || "📄";
}
+2 -5
View File
@@ -26,16 +26,13 @@ export function createMessage(
content: string,
attachments?: ChatAttachment[],
): ChatMessage {
const msg: ChatMessage = {
return {
id: crypto.randomUUID(),
role,
content,
attachments: attachments && attachments.length > 0 ? attachments : undefined,
timestamp: new Date().toISOString(),
};
if (attachments && attachments.length > 0) {
msg.attachments = attachments;
}
return Object.freeze(msg);
}
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
@@ -100,14 +100,7 @@ export function toYaml(config: ConfigData): string {
if (!o) return;
lines.push(`${k}:`);
Object.entries(o).forEach(([sk, sv]) => {
if (sv === undefined || sv === null || sv === "") return;
if (Array.isArray(sv)) {
// Nested list block: e.g. required_env: [KEY, SECRET]
lines.push(` ${sk}:`);
sv.forEach((v) => lines.push(` - ${v}`));
} else {
lines.push(` ${sk}: ${sv}`);
}
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
});
};
@@ -128,7 +121,7 @@ export function toYaml(config: ConfigData): string {
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
lines.push(""); list("skills", config.skills);
lines.push(""); list("tools", config.tools);
if (config.tools?.length) { list("tools", config.tools); }
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
+1 -16
View File
@@ -25,7 +25,6 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
const byId = new Map(nodes.map((n) => [n.id, n]));
const visited = new Set<string>();
const out: T[] = [];
const visit = (n: T) => {
if (visited.has(n.id)) return;
if (n.parentId) {
@@ -35,21 +34,7 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
visited.add(n.id);
out.push(n);
};
// Separate roots (no parentId) from orphans (parentId has no entry in byId).
// Visit roots first so they appear before orphans in the output.
const roots: T[] = [];
const orphans: T[] = [];
for (const n of nodes) {
if (!n.parentId || byId.has(n.parentId)) {
roots.push(n);
} else {
orphans.push(n);
}
}
for (const n of roots) visit(n);
for (const n of orphans) visit(n);
for (const n of nodes) visit(n);
return out;
}
-22
View File
@@ -269,28 +269,6 @@ Each workspace exposes an A2A server, builds an Agent Card, and registers with t
But the long-term collaboration model remains direct workspace-to-workspace communication via A2A.
## Known Limitations
### Playwright / browser system libs are not installed
The base `molecule-ai-workspace-runtime` image (`workspace/Dockerfile`) is built on `python:3.11-slim` with Node.js 22, git, and `gh` — about 500 MB. It deliberately **does not** include the system libraries Chromium needs (`libnss3`, `libatk-bridge2.0-0`, `libxkbcommon0`, `libcups2`, `libdrm2`, `libxcomposite1`, `libxdamage1`, `libxrandr2`, `libgbm1`, `libpango-1.0-0`, `libasound2`, etc.). Adding them would inflate the image by ~200250 MB (~40%) for every workspace, even though only frontend / QA workspaces ever launch a browser.
Practical consequences:
- `npx playwright test` (and any other Chromium-driven E2E tooling) **will fail at browser launch** when run from inside an in-container workspace agent.
- The error surface is missing-shared-object messages such as `error while loading shared libraries: libnss3.so` or `Host system is missing dependencies to run browsers`.
- Unit and integration tests (Vitest, Jest, etc.) that don't spawn a real browser are unaffected.
Recommended workflow:
1. **Run E2E in CI**, not in-container. The Gitea Actions self-hosted runner (and the GitHub Actions runner used by mirror repos) has the full Playwright dep set installed and is the supported surface for E2E. Push a branch, let CI run the suite.
2. **Local debugging** of a single failing spec is best done on a developer laptop with `npx playwright install-deps` run once.
3. **In-container iteration** on test logic itself is fine — write specs, lint them, type-check them — just don't expect `playwright test` to actually launch a browser.
If a particular workspace role genuinely needs in-container E2E (a dedicated QA template, for instance), the right place to layer Playwright deps is in a **role-specific adapter template image** that does `FROM molecule-ai-workspace-runtime:<tag>` and adds `RUN npx playwright install-deps`. Open a request against `molecule-ai-workspace-runtime` if you need this template stamped.
Tracking issue: [molecule-ai/molecule-app#7](https://git.moleculesai.app/molecule-ai/molecule-app/issues/7).
## Related Docs
- [Agent Runtime Adapters](./cli-runtime.md)
-1
View File
@@ -44,4 +44,3 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+5 -45
View File
@@ -37,50 +37,6 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
EXPECTED=0
CLONED=0
# clone_one_with_retry — clone a single repo, retrying on transient failure.
#
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
# clone the full manifest (~36 repos) serially on a memory-constrained
# Gitea Actions runner. Under host memory pressure the OOM killer
# occasionally SIGKILLs git-remote-https mid-clone:
#
# error: git-remote-https died of signal 9
# fatal: the remote end hung up unexpectedly
#
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
# job died on the 14th of 36 clones, which wedged staging→main). One
# transient SIGKILL / network blip would otherwise fail the whole tenant
# image rebuild. Retrying after a short backoff lets the pressure subside.
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
# just stops a single flake from being release-blocking.
#
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
clone_one_with_retry() {
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
local attempt=1 max_attempts=3 backoff
while : ; do
# A killed attempt can leave a partial directory behind; git clone
# refuses a non-empty target, so wipe it before each try.
rm -rf "$tdir/$name"
if [ "$ref" = "main" ]; then
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
else
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
return 1
fi
backoff=$((attempt * 3)) # 3s, then 6s
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
sleep "$backoff"
attempt=$((attempt + 1))
done
}
clone_category() {
local category="$1"
local target_dir="$2"
@@ -126,7 +82,11 @@ clone_category() {
fi
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
if [ "$ref" = "main" ]; then
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
else
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
fi
CLONED=$((CLONED + 1))
i=$((i + 1))
done
@@ -717,16 +717,13 @@ func deriveProviderFromModelSlug(model string) string {
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// Resolution order (priority high → low):
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
// persona-env convention sometimes (mis)set it to a provider slug
// ("minimax") or a runtime name ("claude-code"), neither a valid
// model id — see internal#226. Only fires when the better-named
// vars are absent.)
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
// *model id* set by canvas Save+Restart's PUT /model; on the
// post-2026-05-08 persona-env convention it's a *provider slug*
// (e.g. "minimax") which is NOT a valid model id, so this fallback
// only fires when MODEL is absent.)
//
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
@@ -739,9 +736,6 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// and the workspace template's adapter routed to providers[0]
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
// during Phase 4 verification of template-claude-code PR #9.
if model == "" {
model = envVars["MOLECULE_MODEL"]
}
if model == "" {
model = envVars["MODEL"]
}
@@ -752,18 +746,16 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
return
}
// Canonical model env varsmolecule-runtime's workspace/config.py
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
// MODEL_PROVIDER (#280). Export both new names so adapters can read
// either; MODEL stays for backwards compat with everything that
// already reads os.environ["MODEL"] (the claude-code adapter does,
// since #194). Without this, the user's canvas selection is silently
// dropped on every templated provision — confirmed via crash-loop
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
// FIRST so the per-runtime branches below can layer on additional
// vendor-specific names without fighting over the canonical one.
envVars["MOLECULE_MODEL"] = model
// Universal MODEL env var — every adapter that wants to honour the
// canvas-picked model (instead of its template's default) reads this.
// molecule-runtime's workspace/config.py already falls back to MODEL
// for runtime_config.model (#194). Without this line, the user's
// canvas selection is silently dropped on every templated provision —
// confirmed via crash-loop diagnosis on 2026-05-02 where MiniMax
// picks booted with model=sonnet (template default) and demanded
// CLAUDE_CODE_OAUTH_TOKEN. Set it FIRST so the per-runtime branches
// below can still layer on additional vendor-specific names without
// fighting over the canonical one.
envVars["MODEL"] = model
switch runtime {
@@ -665,62 +665,46 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
runtime string
model string
modelProviderEnv string
moleculeModelEnv string
wantMODEL string
wantHermesDefault string // empty string = must be unset
}{
{
name: "claude-code: picked model populates MODEL + MOLECULE_MODEL",
name: "claude-code: picked model populates MODEL",
runtime: "claude-code",
model: "MiniMax-M2",
wantMODEL: "MiniMax-M2",
},
{
name: "hermes: picked model populates MODEL, MOLECULE_MODEL, HERMES_DEFAULT_MODEL",
name: "hermes: picked model populates BOTH MODEL and HERMES_DEFAULT_MODEL",
runtime: "hermes",
model: "minimax/MiniMax-M2.7",
wantMODEL: "minimax/MiniMax-M2.7",
wantHermesDefault: "minimax/MiniMax-M2.7",
},
{
name: "langgraph: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
name: "langgraph: picked model populates MODEL (no vendor-specific name)",
runtime: "langgraph",
model: "anthropic:claude-opus-4-7",
wantMODEL: "anthropic:claude-opus-4-7",
},
{
name: "crewai: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
name: "crewai: picked model populates MODEL (no vendor-specific name)",
runtime: "crewai",
model: "openai:gpt-4o",
wantMODEL: "openai:gpt-4o",
},
{
name: "empty model + no env fallback: nothing set",
name: "empty model + empty MODEL_PROVIDER fallback: nothing set",
runtime: "claude-code",
model: "",
},
{
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
name: "empty model + MODEL_PROVIDER fallback hits: MODEL set from secret",
runtime: "claude-code",
model: "",
modelProviderEnv: "MiniMax-M2",
wantMODEL: "MiniMax-M2",
},
{
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
runtime: "claude-code",
model: "",
moleculeModelEnv: "opus",
wantMODEL: "opus",
},
{
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
runtime: "claude-code",
model: "",
moleculeModelEnv: "opus",
modelProviderEnv: "claude-code",
wantMODEL: "opus",
},
}
for _, tc := range cases {
@@ -729,18 +713,11 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
if tc.modelProviderEnv != "" {
envVars["MODEL_PROVIDER"] = tc.modelProviderEnv
}
if tc.moleculeModelEnv != "" {
envVars["MOLECULE_MODEL"] = tc.moleculeModelEnv
}
applyRuntimeModelEnv(envVars, tc.runtime, tc.model)
if got := envVars["MODEL"]; got != tc.wantMODEL {
t.Errorf("MODEL = %q, want %q", got, tc.wantMODEL)
}
// MOLECULE_MODEL (the canonical name) must mirror MODEL exactly.
if got := envVars["MOLECULE_MODEL"]; got != tc.wantMODEL {
t.Errorf("MOLECULE_MODEL = %q, want %q", got, tc.wantMODEL)
}
if got := envVars["HERMES_DEFAULT_MODEL"]; got != tc.wantHermesDefault {
t.Errorf("HERMES_DEFAULT_MODEL = %q, want %q", got, tc.wantHermesDefault)
}
-17
View File
@@ -179,23 +179,6 @@ def parse(data: Any) -> Variant:
)
return Malformed(raw=data)
# Push-mode queue envelope — returned when a push-mode workspace
# (one with a public URL) is at capacity. The platform queues the
# request and returns {"queued": true, "message": "...", "queue_id": "..."}.
# Unlike the poll-mode envelope (status=queued + delivery_mode=poll),
# this shape has no delivery_mode key — it's distinguishable by
# data.get("queued") is True alone. Checked before poll-mode so the
# two cases are mutually exclusive even if a buggy server sends both.
if data.get("queued") is True:
method_raw = data.get(_KEY_METHOD)
method = str(method_raw) if method_raw is not None else "message/send"
logger.info(
"a2a_response.parse: queued for busy push-mode peer (method=%s, queue_id=%s)",
method,
data.get("queue_id", "?"),
)
return Queued(method=method)
# Poll-queued envelope. Both keys must be present — the workspace
# server sets them together; if only one is present the body is
# ambiguous and we route to Malformed for visibility.
-24
View File
@@ -204,20 +204,6 @@ async def tool_delegate_task(
if not workspace_id or not task:
return "Error: workspace_id and task are required"
# Self-delegation guard: delegating to your own workspace ID deadlocks —
# the sending turn holds _run_lock while the receive handler waits for the
# same lock, the request 30s-times-out, and the whole cycle is wasted.
# Reject immediately with an actionable message. (effective_src mirrors the
# `src or WORKSPACE_ID` resolution used below for routing.)
effective_src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
if workspace_id and workspace_id == effective_src:
return (
"Error: cannot delegate_task to your own workspace — self-delegation "
"deadlocks _run_lock (your sending turn holds it, the receive handler "
"waits for it, the request times out). There is no peer who is also you: "
"just do the work yourself, or call commit_memory / send_message_to_user directly."
)
# Auto-route: if source not specified, look up which registered
# workspace last saw this peer (populated by tool_list_peers). Falls
# back to the legacy WORKSPACE_ID for single-workspace operators.
@@ -337,16 +323,6 @@ async def tool_delegate_task_async(
src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
# Self-delegation guard: even on the async path, queuing a task to your own
# workspace just makes you re-process your own dispatch — never useful, and
# on the sync path it deadlocks (see tool_delegate_task). Reject early.
if workspace_id and workspace_id == src:
return (
"Error: cannot delegate_task_async to your own workspace — there is no "
"peer who is also you. Do the work yourself, or call commit_memory / "
"send_message_to_user directly."
)
# Idempotency key: SHA-256 of (source, target, task) so that a
# restarted agent firing the same delegation gets the same key and
# the platform returns the existing delegation_id instead of
+3 -28
View File
@@ -66,35 +66,10 @@ async def delegate_task(workspace_id: str, task: str) -> str:
)
data = a2a_resp.json()
if "result" in data:
result = data["result"]
parts = result.get("parts", []) if isinstance(result, dict) else []
if parts and isinstance(parts[0], dict):
return parts[0].get("text", "(no text)")
# Empty parts list (e.g. {"parts": []}) should return str(result),
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
if isinstance(result, dict) and result.get("parts") == []:
return str(result)
return str(result) if isinstance(result, str) else "(no text)"
parts = data["result"].get("parts", [])
return parts[0].get("text", "(no text)") if parts else str(data["result"])
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
return f"Error: {data['error'].get('message', str(data['error']))}"
return str(data)
except Exception as e:
return f"Error sending A2A message: {e}"
+8 -54
View File
@@ -1,6 +1,5 @@
"""Load workspace configuration from config.yaml."""
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -8,8 +7,6 @@ from typing import Optional
import yaml
logger = logging.getLogger(__name__)
@dataclass
class RBACConfig:
@@ -384,47 +381,6 @@ def _derive_provider_from_model(model: str) -> str:
return ""
_legacy_model_provider_warned = False
def _picked_model_from_env(default: str) -> str:
"""Resolve the operator-picked model id from env; newest name wins.
Precedence: ``MOLECULE_MODEL`` (canonical, unambiguous) → ``MODEL`` →
``MODEL_PROVIDER`` (legacy) → ``default`` (the YAML ``model:`` field).
``MODEL_PROVIDER`` is **misleadingly named**: it carries the picked
*model id*, never the LLM provider — the provider lives in
``LLM_PROVIDER`` / the YAML ``provider:`` field. The legacy path stays
so canvas Save+Restart, the workspace-server secret-mint path, and
persona env files that set it keep working, but if it's the *only* one
set we log a deprecation once — the misnomer keeps biting (e.g. setting
``MODEL_PROVIDER=claude-code`` expecting it to select the claude-code
*runtime* — it doesn't, ``runtime:`` does — after which the claude CLI
404s on ``--model claude-code``). Set ``MODEL``/``MOLECULE_MODEL`` to
an id from ``runtime_config.models[].id`` (e.g. ``opus``, ``sonnet``,
``claude-opus-4-7``, ``MiniMax-M2.7-highspeed``) instead.
"""
global _legacy_model_provider_warned
for name in ("MOLECULE_MODEL", "MODEL"):
v = (os.environ.get(name) or "").strip()
if v:
return v
legacy = (os.environ.get("MODEL_PROVIDER") or "").strip()
if legacy:
if not _legacy_model_provider_warned:
logger.warning(
"MODEL_PROVIDER=%r is deprecated and misleadingly named — it "
"sets the picked *model id*, not the LLM provider (that's "
"LLM_PROVIDER / the YAML `provider:` field). Set MODEL (or "
"MOLECULE_MODEL) to an id from runtime_config.models instead.",
legacy,
)
_legacy_model_provider_warned = True
return legacy
return default
_EVENT_LOG_VALID_BACKENDS = {"memory", "disabled"}
@@ -489,10 +445,8 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
with open(config_file) as f:
raw = yaml.safe_load(f) or {}
# Operator-picked model from env (canvas / secret-mint / persona env),
# falling back to the YAML `model:` field. See _picked_model_from_env for
# the precedence (MOLECULE_MODEL > MODEL > legacy MODEL_PROVIDER).
model = _picked_model_from_env(raw.get("model", "anthropic:claude-opus-4-7"))
# Override model from env if provided
model = os.environ.get("MODEL_PROVIDER", raw.get("model", "anthropic:claude-opus-4-7"))
# Resolve top-level provider with this priority chain:
# 1. ``LLM_PROVIDER`` env var (canvas Save+Restart sets this so the
@@ -563,9 +517,8 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
required_env=runtime_raw.get("required_env", []),
timeout=runtime_raw.get("timeout", 0),
# Picked-model precedence (priority order):
# 1. operator-picked model from env — MOLECULE_MODEL > MODEL >
# (legacy) MODEL_PROVIDER, plumbed via canvas Save+Restart,
# workspace-server's secret-mint path, or the universal
# 1. MODEL_PROVIDER env var — canvas-picked model, plumbed via
# workspace-server's secret-mint path or the universal
# MODEL/MODEL_PROVIDER env from applyRuntimeModelEnv. The
# operator's canvas selection MUST win over the template's
# baked-in default; previously the template's
@@ -574,12 +527,13 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
# surfaced 2026-05-02 during E2E).
# 2. runtime_raw.model — explicit YAML override in the
# template's runtime_config.
# 3. top-level `model` (already env-resolved above). This is
# the SaaS restart case (CP regenerates a minimal
# 3. top-level `model` already honors MODEL_PROVIDER (line
# 359) but only when YAML lacks a top-level `model:`. This
# is the SaaS restart case (CP regenerates a minimal
# config.yaml on every boot, dropping runtime_config.model).
# Centralising here means EVERY adapter gets the override for
# free — no per-adapter env-reading code required.
model=_picked_model_from_env(runtime_raw.get("model") or model),
model=os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model,
# Same fallback shape as ``model`` above: an explicit
# ``runtime_config.provider`` wins; otherwise inherit the
# top-level resolved provider so adapters see a single
-16
View File
@@ -51,22 +51,6 @@ class AdaptorSource:
def _load_module_from_path(module_name: str, path: Path):
"""Import a Python file by absolute path. Returns the module or None on failure."""
# Ensure the plugins_registry package and its submodules are importable in the
# fresh module namespace created by module_from_spec(). Plugin adapters
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
# which requires plugins_registry and its submodules to already be in sys.modules.
# We import and register them before exec_module so the plugin's own
# from ... import statements resolve correctly.
import sys
import plugins_registry
sys.modules.setdefault("plugins_registry", plugins_registry)
for _sub in ("builtins", "protocol", "raw_drop"):
try:
sub = importlib.import_module(f"plugins_registry.{_sub}")
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
except Exception:
# Submodule may not exist in all versions; skip if absent.
pass
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
return None
@@ -1,60 +0,0 @@
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
can be loaded via _load_module_from_path() without ModuleNotFoundError.
"""
import sys
import tempfile
import os
from pathlib import Path
# Ensure the plugins_registry package is importable
import plugins_registry
from plugins_registry import _load_module_from_path
def test_load_adapter_with_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
# Write a temp adapter file that does the exact import from the bug report.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
f.write("assert Adaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
finally:
os.unlink(adapter_path)
def test_load_adapter_with_full_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry import InstallContext, resolve\n")
f.write("from plugins_registry.protocol import PluginAdaptor\n")
f.write("assert InstallContext is not None\n")
f.write("assert resolve is not None\n")
f.write("assert PluginAdaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter_full", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
assert hasattr(module, "resolve"), "module should expose resolve"
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
finally:
os.unlink(adapter_path)
if __name__ == "__main__":
test_load_adapter_with_plugins_registry_import()
test_load_adapter_with_full_plugins_registry_import()
print("ALL TESTS PASS")
@@ -127,51 +127,3 @@ class TestPollBudgetEnvOverride:
# numeric and >= the documented floor (180s healthsweep budget).
assert isinstance(a2a_tools_delegation._SYNC_POLL_BUDGET_S, float)
assert a2a_tools_delegation._SYNC_POLL_BUDGET_S >= 180.0
# ============== Self-delegation guard ==============
class TestSelfDelegationGuard:
"""delegate_task / delegate_task_async to your own workspace ID must be
rejected immediately (it deadlocks _run_lock on the sync path — the
sending turn holds the lock, the receive handler waits for it, the
request 30s-times-out). A genuinely different target must NOT be
short-circuited by the guard."""
def _fresh(self, monkeypatch, own_id):
import a2a_tools_delegation as d
monkeypatch.setattr(d, "WORKSPACE_ID", own_id)
monkeypatch.setattr(d, "_peer_to_source", {}, raising=False)
return d
def test_delegate_task_rejects_self(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
out = asyncio.run(d.tool_delegate_task("ws-self-abc", "do a thing"))
assert "your own workspace" in out.lower()
def test_delegate_task_rejects_self_via_explicit_source(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-other-default")
out = asyncio.run(
d.tool_delegate_task("ws-X", "do a thing", source_workspace_id="ws-X")
)
assert "your own workspace" in out.lower()
def test_delegate_task_async_rejects_self(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
out = asyncio.run(d.tool_delegate_task_async("ws-self-abc", "do a thing"))
assert "your own workspace" in out.lower()
def test_delegate_task_allows_different_target(self, monkeypatch):
"""Guard passes through for a real peer — it reaches discover_peer
(stubbed to 'not found' here) rather than returning the self message."""
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
async def _no_peer(*_a, **_kw):
return None
monkeypatch.setattr(d, "discover_peer", _no_peer)
out = asyncio.run(d.tool_delegate_task("ws-OTHER-xyz", "do a thing"))
assert "your own workspace" not in out.lower()
assert "not found" in out.lower()
-87
View File
@@ -1,12 +1,10 @@
"""Tests for config.py — workspace configuration loading."""
import logging
import os
import pytest
import yaml
import config
from config import (
A2AConfig,
ComplianceConfig,
@@ -19,17 +17,6 @@ from config import (
)
@pytest.fixture(autouse=True)
def _clean_model_env(monkeypatch):
"""Every test starts with no MODEL* env vars set and the legacy-name
deprecation latch reset, so picked-model resolution is deterministic
regardless of the CI shell environment or test ordering."""
for name in ("MOLECULE_MODEL", "MODEL", "MODEL_PROVIDER"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config, "_legacy_model_provider_warned", False, raising=False)
yield
def test_load_config_basic(tmp_path):
"""load_config reads a YAML file and returns a WorkspaceConfig."""
config_yaml = tmp_path / "config.yaml"
@@ -177,80 +164,6 @@ def test_runtime_config_model_env_wins_over_explicit_yaml(tmp_path, monkeypatch)
assert cfg.runtime_config.model == "minimax/MiniMax-M2.7"
def test_picked_model_MODEL_env_wins_over_legacy_MODEL_PROVIDER(tmp_path, monkeypatch):
"""MODEL (the correctly-named env var) beats the legacy MODEL_PROVIDER.
Regression for the 2026-05-10 dev-team incident: lead persona env files
set MODEL=claude-opus-4-7 (the intended model) AND MODEL_PROVIDER=claude-code
(mistaking MODEL_PROVIDER for "the runtime"). The old code read
MODEL_PROVIDER → the claude CLI got `--model claude-code` → 404. MODEL must
win so the operator's intended value lands at both levels.
"""
monkeypatch.setenv("MODEL", "opus")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(
yaml.dump({"model": "anthropic:claude-opus-4-7",
"runtime_config": {"model": "sonnet"}})
)
cfg = load_config(str(tmp_path))
assert cfg.model == "opus"
assert cfg.runtime_config.model == "opus"
def test_picked_model_MOLECULE_MODEL_wins_over_MODEL(tmp_path, monkeypatch):
"""MOLECULE_MODEL (the unambiguous canonical name) wins over MODEL, which
in turn wins over the legacy MODEL_PROVIDER."""
monkeypatch.setenv("MOLECULE_MODEL", "claude-opus-4-7")
monkeypatch.setenv("MODEL", "sonnet")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
cfg = load_config(str(tmp_path))
assert cfg.model == "claude-opus-4-7"
assert cfg.runtime_config.model == "claude-opus-4-7"
def test_picked_model_MODEL_env_overrides_yaml(tmp_path, monkeypatch):
"""MODEL env overrides the YAML `model:` field — same role MODEL_PROVIDER
had, now under the correctly-named var."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL", "google:gemini-2.0-flash")
cfg = load_config(str(tmp_path))
assert cfg.model == "google:gemini-2.0-flash"
def test_legacy_MODEL_PROVIDER_still_honored_but_warns(tmp_path, monkeypatch, caplog):
"""MODEL_PROVIDER alone still resolves the model (back-compat: canvas
Save+Restart, secret-mint, existing persona env files keep working) but
logs a one-time deprecation pointing at the misnomer."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
with caplog.at_level(logging.WARNING):
cfg = load_config(str(tmp_path))
assert cfg.model == "MiniMax-M2.7-highspeed"
assert cfg.runtime_config.model == "MiniMax-M2.7-highspeed"
assert any(
"MODEL_PROVIDER" in r.getMessage() and "deprecated" in r.getMessage()
for r in caplog.records
)
def test_no_deprecation_when_MODEL_is_set(tmp_path, monkeypatch, caplog):
"""When MODEL is set, MODEL_PROVIDER is ignored entirely and NOT warned
about — a workspace that already does it right shouldn't get nagged."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL", "opus")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
with caplog.at_level(logging.WARNING):
cfg = load_config(str(tmp_path))
assert cfg.model == "opus"
assert not any("MODEL_PROVIDER" in r.getMessage() for r in caplog.records)
def test_runtime_config_model_picks_up_env_via_top_level(tmp_path, monkeypatch):
"""End-to-end path the canvas Save+Restart relies on: user picks
a model → workspace_secrets.MODEL_PROVIDER updated → CP user-data
@@ -1,266 +0,0 @@
"""Tests for shared_runtime helper functions.
Covers the untested helpers in shared_runtime.py:
- _extract_part_text
- extract_message_text
- format_conversation_history
- build_task_text
- append_peer_guidance
- brief_task
Does NOT cover set_current_task (async, covered in test_a2a_executor.py).
"""
from __future__ import annotations
import sys
# Ensure the workspace root is on the path so 'shared_runtime' resolves
_ws_root = __file__.rsplit("/tests/", 1)[0]
if _ws_root not in sys.path:
sys.path.insert(0, _ws_root)
from shared_runtime import (
_extract_part_text,
extract_message_text,
format_conversation_history,
build_task_text,
append_peer_guidance,
brief_task,
)
# ─── _extract_part_text ──────────────────────────────────────────────────────
class TestExtractPartText:
def test_dict_with_text(self):
assert _extract_part_text({"text": "hello world"}) == "hello world"
def test_dict_with_nested_root_text(self):
assert _extract_part_text({"root": {"text": "nested text"}}) == "nested text"
def test_dict_prefers_text_over_root(self):
# When both text and root exist, text wins (outer text)
assert _extract_part_text({"text": "outer", "root": {"text": "inner"}}) == "outer"
def test_dict_empty_text_and_root(self):
assert _extract_part_text({"kind": "text"}) == ""
def test_dict_missing_fields(self):
assert _extract_part_text({"kind": "image"}) == ""
def test_dict_mixed_with_extra_fields(self):
assert _extract_part_text({"kind": "text", "text": "foo", "url": "http://..."}) == "foo"
def test_object_with_text_attribute(self):
class PartObj:
text = "object text"
assert _extract_part_text(PartObj()) == "object text"
def test_object_with_root_text_attribute(self):
class RootObj:
text = "root object text"
class PartObj:
root = RootObj()
assert _extract_part_text(PartObj()) == "root object text"
def test_object_empty_text(self):
class EmptyObj:
text = ""
assert _extract_part_text(EmptyObj()) == ""
def test_object_no_text_or_root(self):
class NoTextObj:
pass
assert _extract_part_text(NoTextObj()) == ""
def test_none_like(self):
assert _extract_part_text(None) == ""
# ─── extract_message_text ────────────────────────────────────────────────────
class TestExtractMessageText:
def test_list_of_dict_parts(self):
parts = [{"text": "hello"}, {"text": "world"}]
assert extract_message_text(parts) == "hello world"
def test_single_part(self):
parts = [{"text": "only one"}]
assert extract_message_text(parts) == "only one"
def test_empty_list(self):
assert extract_message_text([]) == ""
def test_none_parts(self):
assert extract_message_text(None) == ""
def test_object_with_message_parts(self):
"""Object with .message.parts attribute (A2A RequestContext pattern)."""
msg = type("Message", (), {"parts": [{"text": "from context"}, {"text": "message"}]})()
ctx = type("Context", (), {"message": msg})()
assert extract_message_text(ctx) == "from context message"
def test_joins_with_single_space(self):
# Inter-part join uses single space; internal whitespace within parts is preserved
parts = [{"text": "hello"}, {"text": "world"}]
assert extract_message_text(parts) == "hello world"
def test_preserves_within_part_whitespace(self):
parts = [{"text": " spaced "}, {"text": "\ttext\t"}]
# Leading/trailing whitespace stripped; internal whitespace within parts preserved
assert extract_message_text(parts) == "spaced \ttext"
def test_skips_parts_without_text(self):
parts = [{"kind": "image"}, {"text": "visible"}, {"url": "http://x"}]
assert extract_message_text(parts) == "visible"
# ─── format_conversation_history ──────────────────────────────────────────────
class TestFormatConversationHistory:
def test_empty_history(self):
assert format_conversation_history([]) == ""
def test_single_user_message(self):
result = format_conversation_history([("human", "hello")])
assert "User: hello" in result
def test_single_agent_message(self):
result = format_conversation_history([("ai", "hi there")])
assert "Agent: hi there" in result
def test_interleaved_history(self):
history = [
("human", "first"),
("ai", "response one"),
("human", "second"),
("ai", "response two"),
]
result = format_conversation_history(history)
lines = result.strip().split("\n")
assert len(lines) == 4
assert lines[0] == "User: first"
assert lines[1] == "Agent: response one"
assert lines[2] == "User: second"
assert lines[3] == "Agent: response two"
# ─── build_task_text ──────────────────────────────────────────────────────────
class TestBuildTaskText:
def test_no_history_returns_user_message(self):
assert build_task_text("hello", []) == "hello"
def test_history_prepends_transcript(self):
history = [("human", "hi"), ("ai", "hello")]
result = build_task_text("send email", history)
assert "Conversation so far:" in result
assert "User: hi" in result
assert "Agent: hello" in result
assert "Current request: send email" in result
def test_empty_history_returns_user_message(self):
# Empty list should behave like no history
assert build_task_text("hello", []) == "hello"
def test_single_history_entry(self):
result = build_task_text("bye", [("human", "last")])
assert "User: last" in result
assert "Current request: bye" in result
# ─── append_peer_guidance ─────────────────────────────────────────────────────
class TestAppendPeerGuidance:
def test_no_base_text_uses_default(self):
result = append_peer_guidance(
None,
"peer info here",
default_text="default",
tool_name="delegate_task",
)
assert "peer info here" in result
assert "## Peers" in result
assert "delegate_task" in result
assert "default" in result
def test_base_text_preserved(self):
result = append_peer_guidance(
"my prompt",
"peer info",
default_text="fallback",
tool_name="delegate_task",
)
assert "my prompt" in result
assert "## Peers" in result
def test_empty_peers_info_skipped(self):
result = append_peer_guidance(
"my prompt",
"",
default_text="fallback",
tool_name="delegate_task",
)
assert result == "my prompt"
def test_whitespace_trimmed(self):
result = append_peer_guidance(
" prompt ",
" peers ",
default_text="fallback",
tool_name="delegate_task",
)
# Should not double-space
assert " " not in result
def test_tool_name_injected(self):
result = append_peer_guidance(
None,
"peer info",
default_text="default",
tool_name="my_tool",
)
assert "my_tool" in result
# ─── brief_task ───────────────────────────────────────────────────────────────
class TestBriefTask:
def test_short_text_unchanged(self):
assert brief_task("hello world") == "hello world"
def test_exactly_at_limit(self):
text = "a" * 60
assert brief_task(text) == text
def test_over_limit_truncates(self):
text = "a" * 100
result = brief_task(text)
assert len(result) == 63 # 60 + "..."
assert result.endswith("...")
def test_under_limit_no_ellipsis(self):
text = "a" * 59
result = brief_task(text)
assert result == text
assert "..." not in result
def test_default_limit_60(self):
text = "a" * 70
result = brief_task(text, limit=60)
assert len(result) == 63
def test_custom_limit(self):
text = "a" * 20
result = brief_task(text, limit=10)
assert len(result) == 13 # 10 + "..."
def test_empty_string(self):
assert brief_task("") == ""
assert brief_task("") == "" # no ellipsis for empty