paperclip/ui/src/components/RunWorkspaceRecoverySurface...

218 lines
7.3 KiB
TypeScript

// @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());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: issueGetMock,
create: issueCreateMock,
resolveRecoveryAction: resolveRecoveryMock,
},
}));
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.clearAllMocks();
});
function buildRun(overrides: Partial<HeartbeatRun> = {}): 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(
<QueryClientProvider client={client}>
<ToastProvider>
<RunWorkspaceRecoverySurface run={run} />
</ToastProvider>
</QueryClientProvider>,
);
});
// 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("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" });
});
});