Files
kandev/apps/web/components/task/simple/components/task-launch-error-entry.test.tsx
T
2026-09-15 09:36:47 +01:00

433 lines
13 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry";
import { TaskChatLaunchError } from "./task-chat-launch-error";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary";
const { requestMock, toastMock } = vi.hoisted(() => ({
requestMock: vi.fn(),
toastMock: vi.fn(),
}));
const launchErrorContext = vi.hoisted(() => ({ automaticRecovery: null as unknown }));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: requestMock }),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/task/task-launch-branch-picker", () => ({
TaskLaunchBranchPicker: ({
trigger,
onSelect,
}: {
trigger: ReactNode;
onSelect: (branch: string) => Promise<void>;
}) => (
<>
{trigger}
<button
type="button"
data-testid="mock-branch-option"
onClick={() => void onSelect("develop")}
>
develop
</button>
</>
),
}));
vi.mock("@/components/task/chat/session-bootstrap-recovery-card", () => ({
SessionBootstrapRecoveryCard: ({
error,
automaticRecovery,
}: {
error: TaskStatusSummaryActiveError;
automaticRecovery?: { notice?: string | null; recoveryFailure?: { outcome: string } | null };
}) => (
<div data-testid="session-bootstrap-recovery-card">
{error.phase}
{automaticRecovery?.notice ?? ""}
{automaticRecovery?.recoveryFailure?.outcome ?? ""}
</div>
),
}));
vi.mock("@/components/task/task-launch-error-context", () => ({
useTaskLaunchErrorContext: () => launchErrorContext,
}));
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const SESSION_ID = "session-1";
const ERROR_STAMP = "launch-stamp-1";
const WORKSPACE_ID = "workspace-1";
const OCCURRED_AT = "2026-08-19T10:00:00Z";
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
const RETRY_DEFAULT_BUTTON_TEST_ID = "task-launch-retry_default-button";
const TASK_LAUNCH_RECOVER_METHOD = "task.launch.recover";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
stamp: ERROR_STAMP,
occurred_at: OCCURRED_AT,
preview: "The selected base branch is not available.",
category: "base_branch_missing",
recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"],
};
beforeEach(() => {
requestMock.mockReset().mockResolvedValue({ ok: true });
toastMock.mockReset();
});
afterEach(() => {
cleanup();
launchErrorContext.automaticRecovery = null;
});
// eslint-disable-next-line max-lines-per-function -- shared recovery-entry fixtures keep these related flows together.
describe("TaskLaunchErrorEntry", () => {
it.each(["provider_auth_required", "model_capacity"])(
"does not classify ordinary failure code %s as a launch error",
(category) => {
expect(
isTypedTaskLaunchError({
...error,
category,
}),
).toBe(false);
},
);
it.each([
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
])("classifies launch category %s as a typed launch error", (category) => {
expect(
isTypedTaskLaunchError({
...error,
category,
}),
).toBe(true);
});
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
session_id: SESSION_ID,
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
details: "The pull request head was fetched into its isolated ref.",
category: "workspace_checkout_failed",
recovery_actions: ["retry_launch"],
};
render(
<TaskLaunchErrorEntry taskId={TASK_ID} workspaceId={WORKSPACE_ID} error={checkoutError} />,
);
expect(screen.getByText(checkoutError.preview)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show details/i }));
expect(screen.getByText(checkoutError.details!)).toBeTruthy();
expect(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID)).toBeTruthy();
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
session_id: SESSION_ID,
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
});
it("keeps recovery failures inside the launch card", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
expect(toastMock).not.toHaveBeenCalled();
});
it("clears a recovery failure when a newer launch error arrives", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
const { rerender } = render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
rerender(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{
...error,
stamp: "launch-stamp-2",
recovery_actions: ["retry_launch"],
}}
/>,
);
await waitFor(() => expect(screen.queryByTestId("task-launch-recovery-error")).toBeNull());
});
it("renders a typed summary error without recovery actions", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
statusSummary={{
revision: 1,
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: [] },
}}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
});
it("hides recovery actions for a retained session history entry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
isActive={false}
error={{ ...error, recovery_actions: ["retry_default"] }}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
});
it("routes a session-owned bootstrap error to the recovery card", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId={SESSION_ID}
statusSummary={{
revision: 2,
updated_at: OCCURRED_AT,
active_error: {
...error,
session_id: SESSION_ID,
phase: "bootstrap",
category: "generic_launch_failure",
recovery_actions: ["retry_launch"],
},
}}
/>,
);
expect(screen.getByTestId("session-bootstrap-recovery-card")).toBeTruthy();
expect(screen.queryByTestId(ERROR_ENTRY_TEST_ID)).toBeNull();
});
it("passes automatic recovery results into the detail recovery card", () => {
launchErrorContext.automaticRecovery = {
resumptionState: "error",
error: "Session recovery failed",
notice: null,
recoveryFailure: {
outcome: "recovery_failed",
resumeError: "raw resume failure",
restoreError: "raw restore failure",
},
resumeSession: vi.fn(),
};
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId={SESSION_ID}
statusSummary={{
revision: 3,
updated_at: OCCURRED_AT,
active_error: {
...error,
session_id: SESSION_ID,
phase: "bootstrap",
category: "generic_launch_failure",
recovery_actions: ["retry_launch"],
},
}}
/>,
);
expect(screen.getByTestId("session-bootstrap-recovery-card").textContent).toContain(
"recovery_failed",
);
});
it("renders a task-wide launch error while a prior session is selected", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="prior-session"
statusSummary={{
revision: 1,
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: ["mark_review_done"] },
}}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("task-launch-mark_review_done-button")).toBeTruthy();
});
it("renders the bounded preview and sends the exact task recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[
{
id: TASK_REPOSITORY_ID,
task_id: TASK_ID as TaskRepository["task_id"],
repository_id: "repo-1" as never,
base_branch: "main",
position: 0,
created_at: "2026-08-19T00:00:00Z",
updated_at: "2026-08-19T00:00:00Z",
},
]}
error={error}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "retry_default",
error_stamp: ERROR_STAMP,
});
});
it("includes the selected branch in the row-scoped recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
);
fireEvent.click(screen.getByTestId("mock-branch-option"));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "pick_base_branch",
base_branch: "develop",
error_stamp: ERROR_STAMP,
});
});
it("keeps task-scoped recovery on the task source when it has a session correlation", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={{ ...error, scope: "task", session_id: "retired-session-1" }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "retry_default",
error_stamp: ERROR_STAMP,
});
});
it("waits for a branch selection before sending the picker action", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
);
fireEvent.click(screen.getByTestId("task-launch-pick_base_branch-button"));
await waitFor(() => expect(requestMock).not.toHaveBeenCalled());
});
it("coalesces concurrent recovery requests from duplicate task surfaces", async () => {
let resolveRequest: ((value: { ok: boolean }) => void) | undefined;
requestMock.mockImplementation(
() =>
new Promise<{ ok: boolean }>((resolve) => {
resolveRequest = resolve;
}),
);
render(
<>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
</>,
);
const buttons = screen.getAllByTestId(RETRY_DEFAULT_BUTTON_TEST_ID);
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(buttons[0].getAttribute("disabled")).not.toBeNull();
expect(buttons[1].getAttribute("disabled")).not.toBeNull();
});
resolveRequest?.({ ok: true });
await waitFor(() => expect(screen.queryAllByTestId(ERROR_ENTRY_TEST_ID)).toHaveLength(2));
});
});