Skip to content

Commit f02beeb

Browse files
committed
Add tests
1 parent b9c4b52 commit f02beeb

File tree

8 files changed

+583
-15
lines changed

8 files changed

+583
-15
lines changed

test/mocks/tasks.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
TaskState,
1212
} from "coder/site/src/api/typesGenerated";
1313

14-
import type { TaskTemplate } from "@repo/shared";
14+
import type { TaskDetails, TaskTemplate } from "@repo/shared";
1515

1616
/**
1717
* Create a Task with sensible defaults.
@@ -142,6 +142,25 @@ export function logEntry(overrides: Partial<TaskLogEntry> = {}): TaskLogEntry {
142142
};
143143
}
144144

145+
/** Create a TaskDetails object with sensible defaults */
146+
export function taskDetails(
147+
overrides: Omit<Partial<TaskDetails>, "task"> & {
148+
task?: Partial<Task>;
149+
} = {},
150+
): TaskDetails {
151+
const { task: taskOverrides, ...rest } = overrides;
152+
return {
153+
task: task(taskOverrides ?? {}),
154+
logs: [],
155+
logsStatus: "ok",
156+
canPause: true,
157+
pauseDisabled: false,
158+
canResume: false,
159+
canSendMessage: true,
160+
...rest,
161+
};
162+
}
163+
145164
/** Create a TaskTemplate with sensible defaults */
146165
export function taskTemplate(
147166
overrides: Partial<TaskTemplate> = {},
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { fireEvent, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { ErrorBanner } from "@repo/tasks/components/ErrorBanner";
5+
6+
import { task } from "../../mocks/tasks";
7+
import { renderWithQuery } from "../render";
8+
9+
import type { Task } from "@repo/shared";
10+
11+
const { mockApi } = vi.hoisted(() => ({
12+
mockApi: {
13+
viewLogs: vi.fn(),
14+
},
15+
}));
16+
17+
vi.mock("@repo/tasks/hooks/useTasksApi", () => ({
18+
useTasksApi: () => mockApi,
19+
}));
20+
21+
describe("ErrorBanner", () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
it("renders error message from current_state.message", () => {
27+
const t = task({
28+
status: "error",
29+
current_state: {
30+
state: "failed",
31+
message: "Something broke",
32+
timestamp: "",
33+
uri: "",
34+
},
35+
});
36+
renderWithQuery(<ErrorBanner task={t} />);
37+
expect(screen.getByText("Something broke")).toBeInTheDocument();
38+
});
39+
40+
interface FallbackTestCase {
41+
name: string;
42+
current_state: Task["current_state"];
43+
}
44+
45+
it.each<FallbackTestCase>([
46+
{ name: "null current_state", current_state: null },
47+
{
48+
name: "empty message",
49+
current_state: { state: "failed", message: "", timestamp: "", uri: "" },
50+
},
51+
])('falls back to "Task failed" with $name', ({ current_state }) => {
52+
const t = task({ status: "error", current_state });
53+
renderWithQuery(<ErrorBanner task={t} />);
54+
expect(screen.getByText("Task failed")).toBeInTheDocument();
55+
});
56+
57+
it('calls api.viewLogs when "View logs" is clicked', () => {
58+
const t = task({ id: "task-42", status: "error" });
59+
renderWithQuery(<ErrorBanner task={t} />);
60+
fireEvent.click(screen.getByText("View logs"));
61+
expect(mockApi.viewLogs).toHaveBeenCalledWith("task-42");
62+
});
63+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { fireEvent, screen, waitFor } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { TaskDetailHeader } from "@repo/tasks/components/TaskDetailHeader";
5+
6+
import { task, taskState } from "../../mocks/tasks";
7+
import { qs } from "../helpers";
8+
import { renderWithQuery } from "../render";
9+
10+
import type { Task } from "@repo/shared";
11+
12+
const { mockApi } = vi.hoisted(() => ({
13+
mockApi: {} as Record<string, ReturnType<typeof vi.fn>>,
14+
}));
15+
16+
vi.mock("@repo/tasks/hooks/useTasksApi", () => ({
17+
useTasksApi: () =>
18+
new Proxy(mockApi, {
19+
get: (t, p) => (typeof p === "string" ? (t[p] ?? vi.fn()) : vi.fn()),
20+
}),
21+
}));
22+
23+
describe("TaskDetailHeader", () => {
24+
beforeEach(() => {
25+
for (const key of Object.keys(mockApi)) {
26+
delete mockApi[key];
27+
}
28+
});
29+
30+
it("renders task label from display_name", () => {
31+
renderWithQuery(
32+
<TaskDetailHeader
33+
task={task({ display_name: "My Cool Task" })}
34+
onBack={() => {}}
35+
/>,
36+
);
37+
expect(screen.getByText("My Cool Task")).toBeInTheDocument();
38+
});
39+
40+
it("calls onBack when back arrow is clicked", () => {
41+
const handleBack = vi.fn();
42+
const { container } = renderWithQuery(
43+
<TaskDetailHeader task={task()} onBack={handleBack} />,
44+
);
45+
const backButton = qs<HTMLElement>(container, "vscode-icon");
46+
backButton.click();
47+
expect(handleBack).toHaveBeenCalled();
48+
});
49+
50+
it("renders StatusIndicator with status dot", () => {
51+
const { container } = renderWithQuery(
52+
<TaskDetailHeader task={task({ status: "active" })} onBack={() => {}} />,
53+
);
54+
const dot = qs(container, ".status-dot.active");
55+
expect(dot).toHaveAttribute("title", "Active");
56+
});
57+
58+
interface ActionLabelTestCase {
59+
name: string;
60+
taskOverrides: Partial<Task>;
61+
apiMethod: string;
62+
menuLabel: string;
63+
expectedLabel: string;
64+
}
65+
66+
it.each<ActionLabelTestCase>([
67+
{
68+
name: "Pause",
69+
taskOverrides: {
70+
status: "active",
71+
current_state: taskState("working"),
72+
workspace_id: "ws-1",
73+
},
74+
apiMethod: "pauseTask",
75+
menuLabel: "Pause Task",
76+
expectedLabel: "Pausing...",
77+
},
78+
{
79+
name: "Resume",
80+
taskOverrides: { status: "paused", workspace_id: "ws-1" },
81+
apiMethod: "resumeTask",
82+
menuLabel: "Resume Task",
83+
expectedLabel: "Resuming...",
84+
},
85+
{
86+
name: "Delete",
87+
taskOverrides: {},
88+
apiMethod: "deleteTask",
89+
menuLabel: "Delete",
90+
expectedLabel: "Deleting...",
91+
},
92+
{
93+
name: "Download",
94+
taskOverrides: {},
95+
apiMethod: "downloadLogs",
96+
menuLabel: "Download Logs",
97+
expectedLabel: "Downloading...",
98+
},
99+
])(
100+
"shows $expectedLabel while $name is pending, hides after resolve",
101+
async ({ taskOverrides, apiMethod, menuLabel, expectedLabel }) => {
102+
let resolve!: () => void;
103+
mockApi[apiMethod] = vi.fn().mockReturnValue(
104+
new Promise<void>((r) => {
105+
resolve = r;
106+
}),
107+
);
108+
109+
const { container } = renderWithQuery(
110+
<TaskDetailHeader task={task(taskOverrides)} onBack={() => {}} />,
111+
);
112+
113+
fireEvent.click(qs(container, ".action-menu vscode-icon"));
114+
fireEvent.click(screen.getByText(menuLabel));
115+
116+
await waitFor(() => {
117+
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
118+
});
119+
120+
resolve();
121+
122+
await waitFor(() => {
123+
expect(screen.queryByText(expectedLabel)).not.toBeInTheDocument();
124+
});
125+
},
126+
);
127+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { TaskDetailView } from "@repo/tasks/components/TaskDetailView";
5+
6+
import { logEntry, taskDetails, taskState } from "../../mocks/tasks";
7+
import { qs } from "../helpers";
8+
import { renderWithQuery } from "../render";
9+
10+
import type { Task } from "@repo/shared";
11+
12+
vi.mock("@repo/tasks/hooks/useTasksApi", () => ({
13+
useTasksApi: () => new Proxy({}, { get: () => vi.fn() }),
14+
}));
15+
16+
describe("TaskDetailView", () => {
17+
it("passes onBack to header", () => {
18+
const onBack = vi.fn();
19+
const { container } = renderWithQuery(
20+
<TaskDetailView details={taskDetails()} onBack={onBack} />,
21+
);
22+
qs<HTMLElement>(container, "vscode-icon").click();
23+
expect(onBack).toHaveBeenCalled();
24+
});
25+
26+
it("passes logs to chat history", () => {
27+
const details = taskDetails({
28+
logs: [
29+
logEntry({ id: 1, content: "Starting build..." }),
30+
logEntry({ id: 2, content: "Build complete." }),
31+
],
32+
});
33+
renderWithQuery(<TaskDetailView details={details} onBack={() => {}} />);
34+
expect(screen.getByText("Starting build...")).toBeInTheDocument();
35+
expect(screen.getByText("Build complete.")).toBeInTheDocument();
36+
});
37+
38+
it("shows error banner only when status is error", () => {
39+
const errorDetails = taskDetails({
40+
task: {
41+
status: "error",
42+
current_state: {
43+
state: "failed",
44+
message: "Something went wrong",
45+
timestamp: "",
46+
uri: "",
47+
},
48+
},
49+
});
50+
const { container, rerender } = renderWithQuery(
51+
<TaskDetailView details={errorDetails} onBack={() => {}} />,
52+
);
53+
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
54+
55+
const activeDetails = taskDetails({ task: { status: "active" } });
56+
rerender(<TaskDetailView details={activeDetails} onBack={() => {}} />);
57+
expect(container.querySelector(".error-banner")).not.toBeInTheDocument();
58+
});
59+
60+
interface ThinkingTestCase {
61+
name: string;
62+
taskOverrides: Partial<Task>;
63+
expected: boolean;
64+
}
65+
66+
it.each<ThinkingTestCase>([
67+
{
68+
name: "active+working",
69+
taskOverrides: {
70+
status: "active",
71+
current_state: taskState("working"),
72+
},
73+
expected: true,
74+
},
75+
{
76+
name: "active+complete",
77+
taskOverrides: {
78+
status: "active",
79+
current_state: taskState("complete"),
80+
},
81+
expected: false,
82+
},
83+
{
84+
name: "active with null state",
85+
taskOverrides: { status: "active", current_state: null },
86+
expected: false,
87+
},
88+
{
89+
name: "paused+working",
90+
taskOverrides: {
91+
status: "paused",
92+
current_state: taskState("working"),
93+
},
94+
expected: false,
95+
},
96+
])(
97+
'$name → Thinking... is $expected',
98+
({ taskOverrides, expected }) => {
99+
const details = taskDetails({ task: taskOverrides });
100+
renderWithQuery(
101+
<TaskDetailView details={details} onBack={() => {}} />,
102+
);
103+
const matcher = expect(screen.queryByText("Thinking..."));
104+
if (expected) {
105+
matcher.toBeInTheDocument();
106+
} else {
107+
matcher.not.toBeInTheDocument();
108+
}
109+
},
110+
);
111+
112+
it("passes task status to message input placeholder", () => {
113+
const details = taskDetails({ task: { status: "paused" } });
114+
renderWithQuery(<TaskDetailView details={details} onBack={() => {}} />);
115+
expect(screen.getByRole("textbox")).toHaveAttribute(
116+
"placeholder",
117+
"Send a message to resume the task...",
118+
);
119+
});
120+
121+
it("shows logsStatus error in chat history", () => {
122+
const details = taskDetails({ logsStatus: "error" });
123+
renderWithQuery(<TaskDetailView details={details} onBack={() => {}} />);
124+
expect(screen.getByText("Failed to load logs")).toBeInTheDocument();
125+
});
126+
});

test/webview/tasks/TaskItem.test.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@ import { renderWithQuery } from "../render";
99
import type { Task } from "@repo/shared";
1010

1111
vi.mock("@repo/tasks/hooks/useTasksApi", () => ({
12-
useTasksApi: () => ({
13-
pauseTask: vi.fn(),
14-
resumeTask: vi.fn(),
15-
deleteTask: vi.fn(),
16-
viewInCoder: vi.fn(),
17-
downloadLogs: vi.fn(),
18-
}),
12+
useTasksApi: () => new Proxy({}, { get: () => vi.fn() }),
1913
}));
2014

2115
describe("TaskItem", () => {

test/webview/tasks/TaskList.test.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,7 @@ import { task } from "../../mocks/tasks";
77
import { renderWithQuery } from "../render";
88

99
vi.mock("@repo/tasks/hooks/useTasksApi", () => ({
10-
useTasksApi: () => ({
11-
pauseTask: vi.fn(),
12-
resumeTask: vi.fn(),
13-
deleteTask: vi.fn(),
14-
viewInCoder: vi.fn(),
15-
downloadLogs: vi.fn(),
16-
}),
10+
useTasksApi: () => new Proxy({}, { get: () => vi.fn() }),
1711
}));
1812

1913
describe("TaskList", () => {

0 commit comments

Comments
 (0)