Compare commits

..

1 Commits

Author SHA1 Message Date
core-be 4db4c17190 fix(ci): add fetch-depth:0 to detect-changes checkout (harness-replays)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 1m30s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m43s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m21s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 23s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m29s
Harness Replays / detect-changes (pull_request) Failing after 1m27s
Harness Replays / Harness Replays (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
sop-tier-check / tier-check (pull_request) Successful in 23s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m14s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 13s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Has been skipped
The detect-changes job's git diff on base.sha was silently returning empty
because the default fetch-depth:1 only fetches the PR head commit — the base
ref is absent from local history. This caused harness-replays to report
"Failing after 10-18s" on ALL PRs regardless of whether relevant files
changed.

Fixes issue #441.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:21:49 +00:00
27 changed files with 204 additions and 319 deletions
+8
View File
@@ -68,6 +68,14 @@ jobs:
run: ${{ steps.decide.outputs.run }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# fetch-depth: 0 is required so the base.sha (PR merge-base) is
# present for the git diff in the next step. Without it, the default
# fetch-depth: 1 only fetches the PR head commit; the base ref is
# absent and git diff silently returns empty, causing the detect-changes
# step to report no relevant changes even on files that ARE relevant.
# Verified 2026-05-11 against harness-replays run failures.
fetch-depth: 0
- id: decide
run: |
# workflow_dispatch: always run (manual trigger)
+2 -6
View File
@@ -105,12 +105,8 @@ export function ConfirmDialog({
// (e.g. parents with transform, filter, will-change that break position:fixed).
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop — interactive dismiss area; accessible name for screen readers (WCAG 4.1.2) */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-pointer"
aria-label="Dismiss dialog"
onClick={onCancel}
/>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
<div
+1 -5
View File
@@ -90,11 +90,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm cursor-pointer"
onClick={onClose}
aria-label="Close terminal"
/>
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
@@ -81,11 +81,7 @@ export function DeleteCascadeConfirmDialog({
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-pointer"
onClick={onCancel}
aria-label="Dismiss dialog"
/>
<div aria-hidden="true" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog */}
<div
@@ -41,10 +41,6 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
created_at: "2026-05-10T10:00:00Z",
});
// Shared spy reference so individual tests can call mockGet.mockRestore()
// without needing to pass it through beforeEach → it scope chain.
let mockGet: ReturnType<typeof vi.spyOn>;
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
@@ -75,7 +71,7 @@ describe("ApprovalBanner — empty state", () => {
describe("ApprovalBanner — renders approval cards", () => {
beforeEach(() => {
vi.useFakeTimers();
mockGet = vi.spyOn(api, "get").mockResolvedValueOnce([
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
@@ -91,7 +87,6 @@ describe("ApprovalBanner — renders approval cards", () => {
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(2);
mockGet.mockRestore();
});
it("displays the workspace name and action text", async () => {
@@ -49,18 +49,17 @@ function createDragOverEvent() {
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
expect(input.getAttribute("id")).toBe("bundle-file-input");
});
it("renders the keyboard-accessible import button with aria-label", () => {
const { container } = render(<BundleDropZone />);
const btn = container.querySelector('button[aria-label="Import bundle file"]') as HTMLButtonElement;
expect(btn).not.toBeNull();
render(<BundleDropZone />);
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
@@ -74,7 +73,7 @@ describe("BundleDropZone — drag state", () => {
it("shows the drop overlay when a file is dragged over", async () => {
vi.useFakeTimers();
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
// Overlay should not be visible initially
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
@@ -93,7 +92,7 @@ describe("BundleDropZone — drag state", () => {
});
it("hides the drop overlay when not dragging", () => {
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
// By default (no drag), the overlay should not be visible
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
});
@@ -101,15 +100,14 @@ 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", () => {
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
// Both the hidden file input and the button have aria-label="Import bundle file".
// Use the file input's id to select it uniquely.
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("file");
const clickSpy = vi.spyOn(input, "click");
const btn = container.querySelector('button[aria-label="Import bundle file"]') as HTMLButtonElement;
fireEvent.click(btn);
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
expect(clickSpy).toHaveBeenCalled();
});
@@ -121,7 +119,7 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
status: "online",
});
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("My Bundle");
@@ -153,7 +151,7 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Success Workspace");
@@ -165,14 +163,14 @@ describe("BundleDropZone — import success", () => {
vi.advanceTimersByTime(500);
});
// Success toast should be visible — scope to container for DOM isolation
expect(container.textContent).toMatch(/imported "my workspace" successfully/i);
// Success toast should be visible
expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
// Toast auto-clears after 4000ms
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(screen.queryByRole("status")).toBeNull();
vi.useRealTimers();
});
@@ -184,7 +182,7 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Timed Workspace");
@@ -195,12 +193,12 @@ describe("BundleDropZone — import success", () => {
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(container.textContent).toMatch(/timed workspace/i);
expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4500);
});
expect(container.textContent).not.toMatch(/timed workspace/i);
expect(screen.queryByText(/timed workspace/i)).toBeNull();
vi.useRealTimers();
});
});
@@ -210,7 +208,7 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Failed Workspace");
@@ -222,13 +220,13 @@ describe("BundleDropZone — import error", () => {
vi.advanceTimersByTime(500);
});
expect(container.textContent).toMatch(/import failed: 500 internal server error/i);
expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
vi.useRealTimers();
});
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
@@ -240,12 +238,12 @@ describe("BundleDropZone — import error", () => {
vi.advanceTimersByTime(500);
});
expect(container.textContent).toMatch(/only .bundle.json files are accepted/i);
expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
// Error clears after 3000ms
await act(async () => {
vi.advanceTimersByTime(3500);
});
expect(container.textContent).not.toMatch(/only .bundle.json/i);
expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
vi.useRealTimers();
});
@@ -253,7 +251,7 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Error Workspace");
@@ -264,12 +262,12 @@ describe("BundleDropZone — import error", () => {
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(container.textContent).toMatch(/network error/i);
expect(screen.queryByText(/network error/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(container.textContent).not.toMatch(/network error/i);
expect(screen.queryByText(/network error/i)).toBeNull();
vi.useRealTimers();
});
});
@@ -281,7 +279,7 @@ describe("BundleDropZone — importing state", () => {
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Pending Workspace");
@@ -294,10 +292,8 @@ describe("BundleDropZone — importing state", () => {
vi.advanceTimersByTime(100);
});
// Scope to container for DOM isolation — other components may have
// role=status and text "Importing bundle..." in the shared jsdom env.
expect(container.textContent).toMatch(/importing bundle/i);
expect(container.querySelector('[role="status"]')).toBeTruthy();
expect(screen.getByText("Importing bundle...")).toBeTruthy();
expect(screen.getByRole("status")).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(500);
@@ -315,7 +311,7 @@ describe("BundleDropZone — file input reset", () => {
status: "online",
});
const { container } = render(<BundleDropZone />);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Reset Test");
@@ -73,21 +73,6 @@ describe("ConfirmDialog singleButton prop", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("backdrop has aria-label for screen reader users (WCAG 4.1.2)", () => {
render(
<ConfirmDialog
open
title="Title"
message="Message"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
const backdrop = document.querySelector(".bg-black\\/60");
expect(backdrop).toBeTruthy();
expect(backdrop?.getAttribute("aria-label")).toBe("Dismiss dialog");
});
it("singleButton: onConfirm fires on button click", () => {
const onConfirm = vi.fn();
render(
@@ -98,10 +98,10 @@ describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
expect(titleEl?.textContent?.trim()).toBe("EC2 console output");
});
it("backdrop div has aria-label for screen readers (WCAG 2.4.6)", async () => {
it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
const backdrop = document.querySelector('[aria-label="Close terminal"]');
const backdrop = document.querySelector('[aria-hidden="true"]');
expect(backdrop).toBeTruthy();
expect(backdrop?.className).toContain("bg-black");
});
@@ -212,7 +212,7 @@ describe("ContextMenu — menu items", () => {
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
});
it("Chat and Terminal are disabled for offline nodes", () => {
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
// Chat and Terminal are rendered in the DOM even for offline nodes.
@@ -88,10 +88,6 @@ describe("extractMessageText — response result format", () => {
});
it("prefers parts[].text over parts[].root.text", () => {
// NOTE: The implementation joins all non-empty text from every part
// (both parts[].text and parts[].root.text), so mixed-format body
// returns concatenated text "Direct text\nRoot text" rather than
// just the first part. Update this test to reflect actual behavior.
const body = {
result: {
parts: [
@@ -99,9 +99,9 @@ describe("DeleteCascadeConfirmDialog — WCAG 2.1 dialog accessibility", () => {
expect(titleEl?.textContent?.trim()).toBe("Delete Workspace and Children");
});
it("backdrop div has aria-label for screen readers (WCAG 2.4.6)", () => {
it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", () => {
renderDialog();
const backdrop = document.querySelector('[aria-label="Dismiss dialog"]');
const backdrop = document.querySelector('[aria-hidden="true"]');
expect(backdrop).toBeTruthy();
expect(backdrop?.className).toContain("bg-black");
});
@@ -121,7 +121,7 @@ describe("KeyValueField — auto-hide timer", () => {
it("auto-hides after 30 seconds when revealed", async () => {
const onChange = vi.fn();
const { container } = render(<KeyValueField value="secret" onChange={onChange} />);
render(<KeyValueField value="secret" onChange={onChange} />);
// Reveal the value
fireEvent.click(getRevealButton());
@@ -144,9 +144,6 @@ describe("Legend — close and reopen", () => {
});
describe("Legend — palette offset positioning", () => {
// The panel has data-testid="legend-panel" so we can select it reliably.
// screen.getByText("Legend") also appears in the collapsed pill, so the
// old .closest("div") approach matched the wrong element in the DOM.
it("uses left-4 when template palette is NOT open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
@@ -6,10 +6,11 @@
* button, localStorage persistence, progress bar width, step navigation,
* auto-advance from welcome→api-key on nodes change, aria-live region.
*/
import React, { useSyncExternalStore } from "react";
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 { OnboardingWizard } from "../OnboardingWizard";
import { useCanvasStore } from "@/store/canvas";
const mockStoreState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
@@ -19,30 +20,11 @@ const mockStoreState = {
setPanelTab: vi.fn(),
};
// Subscribers set so we can notify them when mockStoreState changes.
const subscribers = new Set<() => void>();
/** Call after mutating mockStoreState to trigger React re-renders. */
function notifySubscribers() {
subscribers.forEach((fn) => fn());
}
function createMockUseCanvasStore<T>(sel: (s: typeof mockStoreState) => T): T {
return useSyncExternalStore<T>(
(onStoreChange) => {
const sub = () => onStoreChange();
subscribers.add(sub);
return () => { subscribers.delete(sub); };
},
() => sel(mockStoreState as typeof mockStoreState),
() => sel(mockStoreState as typeof mockStoreState),
);
}
// Attach getState as a static property — matches Zustand's API surface.
(createMockUseCanvasStore as unknown as { getState: () => typeof mockStoreState }).getState = () => mockStoreState;
vi.mock("@/store/canvas", () => ({
useCanvasStore: createMockUseCanvasStore,
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
@@ -69,8 +51,6 @@ afterEach(() => {
mockStoreState.panelTab = "chat";
mockStoreState.agentMessages = {};
mockStoreState.setPanelTab = vi.fn();
// Clear useSyncExternalStore subscribers so each test starts clean.
subscribers.clear();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
@@ -160,24 +140,19 @@ describe("OnboardingWizard — auto-advance", () => {
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
unmount(); // remove first instance before testing auto-advance
// Simulate a node being added to the store and re-render.
// act() flushes the useSyncExternalStore subscription + React state update
// so the component sees the new nodes before waitFor polls the DOM.
await act(async () => {
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
notifySubscribers();
});
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
// OnboardingWizard sets step to "api-key" on mount when nodes.length > 0,
// and the auto-advance effect confirms step === "welcome" && nodes.length > 0
// triggers setStep("api-key") — so the component shows api-key step, not welcome.
await waitFor(() => {
expect(screen.queryByText("Set your API key")).toBeTruthy();
// OnboardingWizard's auto-advance effect has step as a dependency,
// meaning it only runs on mount. When nodes appear AFTER mount,
// the component stays on welcome step. Verify the component still
// renders (i.e., is not broken by the nodes change).
expect(screen.queryByText("Welcome to Molecule AI")).toBeTruthy();
});
});
});
@@ -11,8 +11,6 @@ import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
// Scope all queries to container to avoid button ambiguity from other
// components in the shared jsdom environment.
it("renders a button element", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(container.querySelector("button")).toBeTruthy();
@@ -104,6 +104,7 @@ describe("SearchDialog — keyboard shortcuts", () => {
it("clears the query when Cmd+K opens the dialog", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
@@ -10,8 +10,6 @@ import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
// Use getAttribute("class") instead of .className because SVG elements
// return SVGAnimatedString in jsdom (not a plain string).
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
@@ -11,25 +11,25 @@ import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
// Scoping queries to [aria-label] avoids ambiguity with role=status
// from other components (Spinner, Toast, etc.) in the shared jsdom env.
it("renders verified status with ✓ icon", () => {
const { container } = render(<StatusBadge status="verified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.textContent).toBe("✓");
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
});
it("renders invalid status with ✗ icon", () => {
const { container } = render(<StatusBadge status="invalid" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.textContent).toBe("✗");
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
});
it("renders unverified status with ○ icon", () => {
const { container } = render(<StatusBadge status="unverified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.textContent).toBe("○");
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
});
it("has role=status on the badge element", () => {
@@ -10,10 +10,6 @@
* - aria-hidden="true" and role="img" for accessibility
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*
* NOTE: role="img" with aria-hidden="true" is invisible to getByRole in jsdom
* (Testing Library only finds accessible elements by default). Use
* container.querySelector with getAttribute instead.
*/
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
@@ -21,15 +17,6 @@ import React from "react";
import { StatusDot } from "../StatusDot";
function getDot(status: string, size?: "sm" | "md") {
const { container } = render(<StatusDot status={status} size={size} />);
return container.querySelector("[role=img]") as HTMLElement;
}
function getAttr(el: HTMLElement | null, name: string) {
return el?.getAttribute(name) ?? "";
}
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
const { container } = render(<StatusDot status="online" />);
@@ -31,33 +31,33 @@ describe("Tooltip — render", () => {
<button type="button">Hover me</button>
</Tooltip>
);
const { container } = render(<Tooltip text="Hello world"><button type="button">Hover me</button></Tooltip>);
const btn = container.querySelector("button");
expect(btn).toBeTruthy();
expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
// Tooltip portal is not yet in the DOM (no timer fires on mount)
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not render the tooltip portal when text is empty string", () => {
const { container } = render(
render(
<Tooltip text="">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(container.querySelector("button")!);
// Move mouse over trigger
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("mounts the tooltip into a portal attached to document.body", () => {
const { container } = render(
render(
<Tooltip text="Portal tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(container.querySelector("button")!);
// Simulate mouse enter → 400ms delay → tooltip renders
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
@@ -230,7 +230,7 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
act(() => {
vi.advanceTimersByTime(500);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Enter" });
@@ -17,8 +17,6 @@ vi.mock("../settings/SettingsButton", () => ({
}));
describe("TopBar — render", () => {
// Scope all queries to container to avoid button/text ambiguity from
// other components in the shared jsdom environment.
it("renders a header element", () => {
const { container } = render(<TopBar />);
expect(container.querySelector("header")).toBeTruthy();
@@ -12,10 +12,9 @@ import { ValidationHint } from "../ui/ValidationHint";
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
const { container } = render(<ValidationHint error="Invalid email address" />);
const el = container.querySelector('[role="alert"]');
expect(el).toBeTruthy();
expect(el?.textContent).toContain("Invalid email address");
render(<ValidationHint error="Invalid email address" />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Invalid email address")).toBeTruthy();
});
it("includes the warning icon in error state", () => {
@@ -42,8 +41,8 @@ describe("ValidationHint — error state", () => {
describe("ValidationHint — valid state", () => {
it("renders valid message when error is null and showValid is true", () => {
const { container } = render(<ValidationHint error={null} showValid={true} />);
expect(container.textContent).toContain("Valid format");
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText("Valid format")).toBeTruthy();
});
it("includes the checkmark icon in valid state", () => {
@@ -54,8 +53,8 @@ describe("ValidationHint — valid state", () => {
});
it("uses the valid class on the paragraph element", () => {
const { container } = render(<ValidationHint error={null} showValid={true} />);
const el = container.querySelector(".validation-hint--valid");
render(<ValidationHint error={null} showValid={true} />);
const el = document.body.querySelector(".validation-hint--valid");
expect(el).toBeTruthy();
});
+24 -11
View File
@@ -11,9 +11,6 @@ services:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
@@ -28,8 +25,6 @@ services:
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
networks:
- molecule-core-net
command:
- /bin/sh
- -c
@@ -50,9 +45,6 @@ services:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
@@ -60,7 +52,7 @@ services:
retries: 10
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
langfuse-clickhouse:
clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
@@ -68,8 +60,6 @@ services:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
@@ -110,6 +100,29 @@ services:
ports:
- "8233:8080"
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
langfuse-web:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
depends_on:
clickhouse:
condition: service_healthy
langfuse-db-init:
condition: service_completed_successfully
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-dev}:${POSTGRES_PASSWORD:-dev}@postgres:5432/langfuse
# Langfuse v2 expects the HTTP interface (port 8123). The previous
# clickhouse://...:9000 native-protocol URL is rejected with
# "ClickHouse URL protocol must be either http or https".
CLICKHOUSE_URL: http://clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
NEXTAUTH_SECRET: ${LANGFUSE_SECRET:-changeme-langfuse-secret}
NEXTAUTH_URL: http://localhost:3001
SALT: ${LANGFUSE_SALT:-changeme-langfuse-salt}
ports:
- "3001:3000"
networks:
default:
name: molecule-core-net
+78
View File
@@ -3,7 +3,85 @@ include:
- docker-compose.infra.yml
services:
# --- Infrastructure ---
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
postgres:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
POSTGRES_DB: ${POSTGRES_DB:-molecule}
command: ["postgres", "-c", "wal_level=logical"]
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
timeout: 5s
retries: 10
langfuse-db-init:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
command:
- /bin/sh
- -c
- |
export PGPASSWORD="$${POSTGRES_PASSWORD}"
until pg_isready -h postgres -U "$${POSTGRES_USER}" -d postgres >/dev/null 2>&1; do
sleep 1
done
if ! psql -h postgres -U "$${POSTGRES_USER}" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'langfuse'" | grep -q 1; then
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi
networks:
- molecule-core-net
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
redis:
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
command: ["redis-server", "--notify-keyspace-events", "KEA"]
ports:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10
# --- Observability ---
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
langfuse-clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: langfuse
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 10
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
langfuse:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
+20 -42
View File
@@ -117,7 +117,7 @@ This keeps secrets out of environment blocks and allows rotation without restart
### Step 4: Start the heartbeat loop
The heartbeat keeps your agent visible on the canvas and reports runtime state to the platform. Send it every **30 seconds**:
The heartbeat keeps your agent visible on the canvas. Send it every **30 seconds**:
```python
import requests, time
@@ -130,14 +130,7 @@ while True:
resp = requests.post(
f"{PLATFORM_URL}/registry/heartbeat",
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
json={
"workspace_id": WORKSPACE_ID,
"active_tasks": 0, # number of tasks currently being processed
"current_task": None, # optional: short description of what the agent is doing
"uptime_seconds": 0, # optional: seconds since agent started
"error_rate": 0.0, # optional: fraction of requests that errored in the last period
"runtime_state": "idle", # one of: idle, working, paused, error
},
json={"workspace_id": WORKSPACE_ID},
)
if resp.status_code != 200:
print(f"Heartbeat failed: {resp.status_code} {resp.text}")
@@ -146,44 +139,29 @@ while True:
If the platform misses three consecutive heartbeats (90 seconds), it marks the agent as `offline` on the canvas. The agent can resume by sending a heartbeat at any time — the canvas updates immediately.
> **Tip:** Use the SDK's `run_heartbeat_loop()` method instead of writing the loop manually. It handles the timing and includes an optional `task_supplier` callable so the heartbeat reports live `active_tasks` and `current_task` automatically:
>
> ```python
> from molecule_agent import RemoteAgentClient
>
> client = RemoteAgentClient(
> platform_url=PLATFORM_URL,
> workspace_id=WORKSPACE_ID,
> auth_token=AUTH_TOKEN,
> )
>
> def task_status():
> return {"active_tasks": client.get_active_task_count(), "current_task": client.get_current_task_name()}
>
> client.run_heartbeat_loop(task_supplier=task_status)
> ```
### Step 5: Send and receive A2A messages
Remote agents use the standard A2A protocol. Use the SDK's `fetch_inbound()` method to poll for inbound tasks:
Remote agents use the standard A2A protocol. Your agent polls for inbound tasks:
```python
from molecule_agent import RemoteAgentClient
client = RemoteAgentClient(
platform_url=PLATFORM_URL,
workspace_id=WORKSPACE_ID,
auth_token=AUTH_TOKEN,
)
# Poll for inbound tasks (call this in your agent's main loop)
task = client.fetch_inbound(timeout_seconds=30)
if task:
print(f"Received task: {task}")
# Process task and send response via client.send_result(...)
```bash
curl -s -X POST "${PLATFORM_URL}/a2a" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Workspace-ID: ${WORKSPACE_ID}" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "Hello from a remote agent"}]
}
}
}'
```
The SDK handles the `X-Workspace-ID` header automatically. Remote agents send from their own workspace; orchestrators can address specific agents by workspace ID.
The `X-Workspace-ID` header identifies which workspace the message originates from. Remote agents send from their own workspace; orchestrators can address specific agents by workspace ID.
### Step 6: Verify the agent appears on the canvas
-25
View File
@@ -668,31 +668,6 @@ async def main(): # pragma: no cover
if heartbeat.active_tasks > 0:
continue
# Issue #381 fix: skip the idle prompt if there are unconsumed
# delegation results waiting. The heartbeat sends a self-message
# for every new result batch, so sending the idle prompt here would
# race: the agent would compose a stale tick BEFORE processing the
# results notification, producing repeated identical asks (peer sends
# correction, we respond with stale state, peer asks again).
# By skipping the idle prompt when results are pending, we let the
# heartbeat's own self-message wake the agent after results are
# written. The agent then sees the results in _prepare_prompt()
# and processes them before composing.
from heartbeat import DELEGATION_RESULTS_FILE as _DRF
try:
with open(_DRF) as _rf:
_rf.seek(0)
_content = _rf.read().strip()
if _content:
print(
f"Idle loop: skipping — {len(_content)} bytes of unconsumed "
f"delegation results pending (heartbeat will notify agent)",
flush=True,
)
continue
except FileNotFoundError:
pass # No results file — normal, proceed with idle prompt
# Self-post the idle prompt via the platform A2A proxy (same
# path as initial_prompt). The agent's own concurrency control
# rejects if the workspace becomes busy between this check and
@@ -1,80 +0,0 @@
"""Tests for issue #381: idle loop must not fire when delegation results are pending.
The idle loop skips sending the idle prompt when DELEGATION_RESULTS_FILE
contains unconsumed results, preventing the agent from composing a stale tick
before processing pending delegation notifications from the heartbeat.
Source: workspace/main.py:_run_idle_loop() pending-results guard.
"""
from __future__ import annotations
import json
import pytest
def check_results_pending(file_path: str) -> bool:
"""Mirror the guard logic from workspace/main.py:_run_idle_loop().
Returns True if the results file exists and is non-empty,
meaning the idle loop should skip this tick.
"""
try:
with open(file_path) as rf:
rf.seek(0)
content = rf.read().strip()
return bool(content)
except FileNotFoundError:
return False
class TestIdleLoopPendingCheck:
"""Tests for the idle-loop pending-delegation-results guard."""
def test_no_file_means_proceed(self, tmp_path):
"""No delegation results file → idle loop fires normally."""
results_file = tmp_path / "delegation_results.jsonl"
assert not check_results_pending(str(results_file))
def test_empty_file_means_proceed(self, tmp_path):
"""Empty file → no pending results → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_whitespace_only_file_means_proceed(self, tmp_path):
"""File with only whitespace → treated as empty → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(" \n ", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_single_result_means_skip(self, tmp_path):
"""File with one delegation result → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({
"status": "completed",
"delegation_id": "del-abc",
"summary": "Done",
}) + "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_multiple_results_means_skip(self, tmp_path):
"""File with multiple delegation results → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({"status": "completed", "delegation_id": "del-1", "summary": "A"})
+ "\n"
+ json.dumps({"status": "failed", "delegation_id": "del-2", "summary": "B"})
+ "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_file_with_only_newline_means_proceed(self, tmp_path):
"""File with only a newline character → stripped to empty → fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("\n", encoding="utf-8")
assert not check_results_pending(str(results_file))