Files
kandev/apps/web/components/task/simple/components/task-launch-error-entry.test.tsx
T
2026-08-21 16:39:47 +01:00

216 lines
6.3 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(),
}));
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>
</>
),
}));
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const ERROR_STAMP = "launch-stamp-1";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
stamp: ERROR_STAMP,
occurred_at: "2026-08-19T10:00:00Z",
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());
// 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",
"generic_launch_failure",
])("classifies launch category %s as a typed launch error", (category) => {
expect(
isTypedTaskLaunchError({
...error,
category,
}),
).toBe(true);
});
it("renders a typed summary error without recovery actions", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId="workspace-1"
statusSummary={{
revision: 1,
updated_at: "2026-08-19T10:00:00Z",
active_error: { ...error, recovery_actions: [] },
}}
runErrors={[]}
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
});
it("renders the bounded preview and sends the exact task recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
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("task-launch-error-entry")).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
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-1"
repositories={[]}
error={error}
/>,
);
fireEvent.click(screen.getByTestId("mock-branch-option"));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "pick_base_branch",
base_branch: "develop",
error_stamp: ERROR_STAMP,
});
});
it("waits for a branch selection before sending the picker action", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
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-1"
repositories={[]}
error={error}
/>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
repositories={[]}
error={error}
/>
</>,
);
const buttons = screen.getAllByTestId("task-launch-retry_default-button");
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("task-launch-error-entry")).toHaveLength(2));
});
});