// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { HeartbeatRun, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { RunWorkspaceRecoverySurface } from "./RunWorkspaceRecoverySurface";
import { ToastProvider } from "../context/ToastContext";
const navigateMock = vi.hoisted(() => vi.fn());
const issueGetMock = vi.hoisted(() => vi.fn());
const issueCreateMock = vi.hoisted(() => vi.fn());
const resolveRecoveryMock = vi.hoisted(() => vi.fn());
const reconcileMock = vi.hoisted(() => vi.fn());
const boardAccessMock = vi.hoisted(() => vi.fn());
const recoveryActionsMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
{children}
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: issueGetMock,
create: issueCreateMock,
resolveRecoveryAction: resolveRecoveryMock,
recoveryActions: recoveryActionsMock,
},
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: {
reconcile: reconcileMock,
},
}));
vi.mock("../api/access", () => ({
accessApi: {
getCurrentBoardAccess: boardAccessMock,
},
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// flushSync-based sync `act` shim (matches the repo pattern in IssueRecoveryActionCard.test.tsx).
function act(callback: () => void): void {
flushSync(callback);
}
// Resolve pending mocked-promise micro/macrotasks, then flush the React state updates react-query
// queued, repeating so chained query → render → dependent-render settles fully.
async function flush() {
for (let i = 0; i < 6; i += 1) {
for (let j = 0; j < 4; j += 1) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
act(() => {});
}
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
vi.resetAllMocks();
});
function buildRun(overrides: Partial = {}): HeartbeatRun {
return {
id: "run-aaaa1111",
companyId: "company-1",
agentId: "agent-1",
status: "failed",
errorCode: "workspace_validation_failed",
contextSnapshot: { issueId: "issue-1" },
resultJson: null,
contextSnapshotJson: null,
...overrides,
} as unknown as HeartbeatRun;
}
function buildRecoveryAction(): IssueRecoveryAction {
return {
id: "action-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: "fp",
evidence: {
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
expectedBranch: "PAP-1405-recorded",
actualBranch: "live-branch",
cleanliness: "dirty",
statusEntryCount: 2,
dirtyPathSample: ["a.ts"],
sourceIdentifier: "PAP-1405",
persistedExecutionWorkspaceId: "ws-1",
provenance: {
expectedHeadSha: "aaaa1111bbbb",
actualHeadSha: "cccc2222dddd",
ancestryVerdict: "diverged",
plainLanguageReason: "Recorded branch is not an ancestor of the live branch.",
},
},
},
nextAction: "Repair the workspace.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-07-09T00:00:00.000Z",
updatedAt: "2026-07-09T00:00:00.000Z",
};
}
function buildIssue(action: IssueRecoveryAction | null): Issue {
return {
id: "issue-1",
identifier: "PAP-1405",
companyId: "company-1",
title: "Do the thing",
description: "body",
priority: "medium",
projectId: null,
parentId: null,
assigneeAgentId: null,
executionWorkspaceId: "ws-1",
activeRecoveryAction: action,
} as unknown as Issue;
}
async function renderSurface(run: HeartbeatRun) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
,
);
});
// Let the issue + board-access queries settle and commit.
await flush();
return container!;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("RunWorkspaceRecoverySurface", () => {
it("inspects a selected resolved source only on request and separates current identity from the recorded failure", async () => {
const action = { ...buildRecoveryAction(), status: "resolved", cause: "native_workspace_sync_out_retry_exhausted",
evidence: { systemRecovery: { failure: { code: "source_binding_invalid" } } } };
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] }).mockResolvedValueOnce({ active: null,
actions: [action], sourceInspection: { actionId: action.id, runId: "run-aaaa1111", scope: "recorded_source_identity",
observedAt: "2026-10-26T17:00:00.000Z", status: "blocked", failureCode: "source_binding_invalid",
sourceVerification: { reason: "lease_policy_not_retained", observedLeasePolicy: "reuse_by_environment" } } });
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
expect(recoveryActionsMock).not.toHaveBeenCalled();
expect(issueGetMock).not.toHaveBeenCalled();
click(node.querySelector("button"));
await flush();
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111" });
expect(node.textContent).toContain("Recorded failure: source_binding_invalid");
const inspect = [...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source");
click(inspect ?? null);
await flush();
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: "action-1" });
expect(node.textContent).toContain("Current recorded-source identity");
expect(node.textContent).toContain("lease_policy_not_retained");
expect(node.textContent).toContain("reuse_by_environment");
expect(node.textContent).toContain("does not confirm provider termination, local files, or successful preservation");
expect(issueCreateMock).not.toHaveBeenCalled();
expect(resolveRecoveryMock).not.toHaveBeenCalled();
expect(reconcileMock).not.toHaveBeenCalled();
});
it("shows verified identity without claiming preservation and reports an inspection error inline", async () => {
const action = buildRecoveryAction();
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] })
.mockResolvedValueOnce({ active: null, actions: [action], sourceInspection: {
actionId: action.id, status: "verified", observedAt: "2026-10-26T17:00:00.000Z" } })
.mockRejectedValueOnce(new Error("Source inspection unavailable"));
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_unrecoverable" }));
click(node.querySelector("button"));
await flush();
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
await flush();
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: action.id });
expect(node.textContent).toContain("Recorded-source identity verified.");
expect(node.textContent).toContain("does not confirm provider termination");
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
await flush();
expect(node.querySelector("[role='alert']")?.textContent).toContain("Source inspection unavailable");
});
it("keeps inspection pending and explains a bounded source limit", async () => {
const action = buildRecoveryAction();
let complete!: (value: unknown) => void;
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] })
.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
click(node.querySelector("button"));
await flush();
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
await flush();
expect(node.querySelector("button")?.disabled).toBe(true);
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: action.id });
complete({ active: null, actions: [action], sourceInspection: { actionId: action.id,
observedAt: "2026-10-26T17:00:00.000Z", status: "not_inspected", reason: "source_limit" } });
await flush();
expect(node.textContent).toContain("exceeds the inspection limit");
expect(node.querySelector("button")?.disabled).toBe(false);
});
it("keeps saved reasons separate from changed observations and preserves other source selections", async () => {
const first = { ...buildRecoveryAction(), evidence: { systemRecovery: { failure: {
code: "source_binding_invalid", sourceVerification: { reason: "lease_policy_not_retained", observedLeasePolicy: "reuse_by_environment" },
} } } };
const second = { ...first, id: "action-2", evidence: { systemRecovery: { failure: { code: "source_binding_invalid",
sourceVerification: { reason: "secret-reason", observedLeasePolicy: "secret-policy" } } } } };
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [first, second] })
.mockResolvedValueOnce({ active: null, actions: [first], sourceInspection: { actionId: first.id,
status: "blocked", observedAt: "2026-10-26T17:00:00.000Z", failureCode: "source_binding_invalid",
sourceVerification: { reason: "source_lease_not_bound" } } })
.mockResolvedValueOnce({ active: null, actions: [second], sourceInspection: { actionId: second.id,
status: "verified", observedAt: "2026-10-26T17:01:00.000Z" } });
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
click(node.querySelector("button"));
await flush();
expect(node.textContent).toContain("Recorded failure: source_binding_invalid · lease_policy_not_retained · lease policy reuse_by_environment");
expect(node.textContent).not.toContain("secret-reason");
expect(node.textContent).not.toContain("secret-policy");
const choices = () => [...node.querySelectorAll("button")].filter(button => button.textContent === "Inspect current source");
click(choices()[0]);
await flush();
expect(node.textContent).toContain("source_lease_not_bound");
expect(node.textContent).toContain("Action action-1");
expect(node.textContent).toContain("lease_policy_not_retained");
expect(choices()).toHaveLength(2);
click(choices()[1]);
await flush();
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: "action-2" });
expect(node.textContent).toContain("Action action-2");
expect(node.textContent).toContain("Recorded-source identity verified.");
expect(choices()).toHaveLength(2);
});
it("renders nothing for a run that is not a workspace-validation failure", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun({ errorCode: "process_lost" }));
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
expect(issueGetMock).not.toHaveBeenCalled();
});
it("renders nothing when the source issue has no workspace_validation recovery action", async () => {
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
});
it("renders the compact recovery card and repair action for a dirty divergence", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
// Compact: metadata rows dropped.
expect(node.textContent).not.toContain("Repair the workspace.");
});
it("wires the repair confirm to reconcile in quarantine_restore mode against the pinned workspace", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
reconcileMock.mockResolvedValue({ id: "ws-1" });
const node = await renderSurface(buildRun());
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "quarantine_restore" });
});
});