@jcjeon/integration-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,282 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ archiveTask,
5
+ createTask,
6
+ deleteTask,
7
+ executeTask,
8
+ fetchTaskConversations,
9
+ fetchTask,
10
+ fetchTaskRuns,
11
+ fetchTasks,
12
+ rerunTask,
13
+ rerunTaskAgent,
14
+ stopTask,
15
+ updateTask,
16
+ } from "../tasks.api";
17
+ import type { CreateTaskPayload } from "../tasks.api";
18
+
19
+ const mockFetch = vi.fn();
20
+
21
+ beforeEach(() => { vi.stubGlobal("fetch", mockFetch); });
22
+ afterEach(() => { vi.unstubAllGlobals(); });
23
+
24
+ function ok(body: unknown) {
25
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(body) } as Response);
26
+ }
27
+ function err(status: number) {
28
+ return Promise.resolve({ ok: false, status } as Response);
29
+ }
30
+
31
+ const mockTask = {
32
+ id: "task-1",
33
+ title: "로그인 구현",
34
+ status: "pending",
35
+ workingDir: null,
36
+ requirements: [],
37
+ agents: [],
38
+ createdAt: "2024-01-01",
39
+ updatedAt: "2024-01-01",
40
+ };
41
+
42
+ describe("createTask", () => {
43
+ const payload: CreateTaskPayload = {
44
+ title: "로그인 구현",
45
+ requirements: [{ content: "UI 디자인", orderIndex: 0 }],
46
+ agents: [{ agentType: "claude", role: "frontend" }],
47
+ };
48
+
49
+ it("sends POST with correct body", async () => {
50
+ mockFetch.mockReturnValueOnce(ok(mockTask));
51
+
52
+ await createTask(payload);
53
+
54
+ expect(mockFetch).toHaveBeenCalledWith(
55
+ expect.stringContaining("/tasks"),
56
+ expect.objectContaining({
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify(payload),
60
+ }),
61
+ );
62
+ });
63
+
64
+ it("returns created task", async () => {
65
+ mockFetch.mockReturnValueOnce(ok(mockTask));
66
+ const result = await createTask(payload);
67
+ expect(result.id).toBe("task-1");
68
+ expect(result.title).toBe("로그인 구현");
69
+ });
70
+
71
+ it("throws on HTTP error", async () => {
72
+ mockFetch.mockReturnValueOnce(err(422));
73
+ await expect(createTask(payload)).rejects.toThrow("HTTP 422");
74
+ });
75
+
76
+ it("includes workingDir when provided", async () => {
77
+ mockFetch.mockReturnValueOnce(ok(mockTask));
78
+ const payloadWithDir = { ...payload, workingDir: "/project" };
79
+ await createTask(payloadWithDir);
80
+ expect(mockFetch).toHaveBeenCalledWith(
81
+ expect.any(String),
82
+ expect.objectContaining({ body: JSON.stringify(payloadWithDir) }),
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("fetchTasks", () => {
88
+ it("returns task list", async () => {
89
+ mockFetch.mockReturnValueOnce(ok([mockTask]));
90
+
91
+ const result = await fetchTasks();
92
+
93
+ expect(result).toHaveLength(1);
94
+ expect(result[0].id).toBe("task-1");
95
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/tasks"));
96
+ });
97
+
98
+ it("returns empty array when no tasks", async () => {
99
+ mockFetch.mockReturnValueOnce(ok([]));
100
+ const result = await fetchTasks();
101
+ expect(result).toHaveLength(0);
102
+ });
103
+
104
+ it("throws on HTTP error", async () => {
105
+ mockFetch.mockReturnValueOnce(err(500));
106
+ await expect(fetchTasks()).rejects.toThrow("HTTP 500");
107
+ });
108
+ });
109
+
110
+ describe("fetchTask", () => {
111
+ it("returns one task", async () => {
112
+ mockFetch.mockReturnValueOnce(ok(mockTask));
113
+
114
+ const result = await fetchTask("task-1");
115
+
116
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/tasks/task-1"));
117
+ expect(result).toEqual(mockTask);
118
+ });
119
+
120
+ it("throws on HTTP error", async () => {
121
+ mockFetch.mockReturnValueOnce(err(404));
122
+ await expect(fetchTask("missing")).rejects.toThrow("HTTP 404");
123
+ });
124
+ });
125
+
126
+ describe("updateTask", () => {
127
+ it("sends PATCH with JSON body", async () => {
128
+ mockFetch.mockReturnValueOnce(ok(mockTask));
129
+
130
+ await updateTask("task-1", { title: "수정" });
131
+
132
+ expect(mockFetch).toHaveBeenCalledWith(
133
+ expect.stringContaining("/tasks/task-1"),
134
+ expect.objectContaining({
135
+ method: "PATCH",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ title: "수정" }),
138
+ }),
139
+ );
140
+ });
141
+
142
+ it("throws on HTTP error", async () => {
143
+ mockFetch.mockReturnValueOnce(err(400));
144
+ await expect(updateTask("task-1", { title: "" })).rejects.toThrow("HTTP 400");
145
+ });
146
+ });
147
+
148
+ describe("archiveTask", () => {
149
+ it("posts to archive endpoint", async () => {
150
+ mockFetch.mockReturnValueOnce(Promise.resolve({ ok: true } as Response));
151
+
152
+ await archiveTask("task-1");
153
+
154
+ expect(mockFetch).toHaveBeenCalledWith(
155
+ expect.stringContaining("/tasks/task-1/archive"),
156
+ { method: "POST" },
157
+ );
158
+ });
159
+
160
+ it("throws on HTTP error", async () => {
161
+ mockFetch.mockReturnValueOnce(err(409));
162
+ await expect(archiveTask("task-1")).rejects.toThrow("HTTP 409");
163
+ });
164
+ });
165
+
166
+ describe("deleteTask", () => {
167
+ it("sends DELETE to correct URL", async () => {
168
+ mockFetch.mockReturnValueOnce(Promise.resolve({ ok: true } as Response));
169
+
170
+ await deleteTask("task-123");
171
+
172
+ expect(mockFetch).toHaveBeenCalledWith(
173
+ expect.stringContaining("/tasks/task-123"),
174
+ expect.objectContaining({ method: "DELETE" }),
175
+ );
176
+ });
177
+
178
+ it("throws on HTTP error", async () => {
179
+ mockFetch.mockReturnValueOnce(err(500));
180
+ await expect(deleteTask("task-123")).rejects.toThrow("HTTP 500");
181
+ });
182
+ });
183
+
184
+ describe("execution controls", () => {
185
+ it("executeTask posts and returns the updated task", async () => {
186
+ mockFetch.mockReturnValueOnce(ok({ ...mockTask, status: "running" }));
187
+
188
+ const result = await executeTask("task-1", [{ agentId: 7, writeTestCode: true }]);
189
+
190
+ expect(mockFetch).toHaveBeenCalledWith(
191
+ expect.stringContaining("/tasks/task-1/execute"),
192
+ {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({ agentTestCodePreferences: [{ agentId: 7, writeTestCode: true }] }),
196
+ },
197
+ );
198
+ expect(result.status).toBe("running");
199
+ });
200
+
201
+ it("stopTask posts and returns the updated task", async () => {
202
+ mockFetch.mockReturnValueOnce(ok({ ...mockTask, status: "stopped" }));
203
+
204
+ const result = await stopTask("task-1");
205
+
206
+ expect(mockFetch).toHaveBeenCalledWith(
207
+ expect.stringContaining("/tasks/task-1/stop"),
208
+ { method: "POST" },
209
+ );
210
+ expect(result.status).toBe("stopped");
211
+ });
212
+
213
+ it("rerunTask posts supplement note and test flag as JSON", async () => {
214
+ mockFetch.mockReturnValueOnce(ok({ ...mockTask, status: "running" }));
215
+
216
+ await rerunTask("task-1", "추가 지시", true);
217
+
218
+ expect(mockFetch).toHaveBeenCalledWith(
219
+ expect.stringContaining("/tasks/task-1/rerun"),
220
+ {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({ supplementNote: "추가 지시", writeTestCode: true }),
224
+ },
225
+ );
226
+ });
227
+
228
+ it("rerunTaskAgent posts supplement note and test flag as JSON", async () => {
229
+ mockFetch.mockReturnValueOnce(ok({ ...mockTask, status: "running" }));
230
+
231
+ await rerunTaskAgent("task-1", 7, "agent note", true);
232
+
233
+ expect(mockFetch).toHaveBeenCalledWith(
234
+ expect.stringContaining("/tasks/task-1/agents/7/rerun"),
235
+ {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({ supplementNote: "agent note", writeTestCode: true }),
239
+ },
240
+ );
241
+ });
242
+
243
+ it("throws when execution endpoints fail", async () => {
244
+ mockFetch.mockReturnValueOnce(err(500));
245
+ await expect(executeTask("task-1")).rejects.toThrow("HTTP 500");
246
+
247
+ mockFetch.mockReturnValueOnce(err(409));
248
+ await expect(stopTask("task-1")).rejects.toThrow("HTTP 409");
249
+
250
+ mockFetch.mockReturnValueOnce(err(422));
251
+ await expect(rerunTask("task-1")).rejects.toThrow("HTTP 422");
252
+ });
253
+ });
254
+
255
+ describe("fetchTaskRuns", () => {
256
+ it("returns run history", async () => {
257
+ const runs = [{ id: 1, version: 1, supplementNote: null, status: "completed", startedAt: "2024-01-01", completedAt: null, agentRuns: [] }];
258
+ mockFetch.mockReturnValueOnce(ok(runs));
259
+
260
+ const result = await fetchTaskRuns("task-1");
261
+
262
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/tasks/task-1/runs"));
263
+ expect(result).toEqual(runs);
264
+ });
265
+
266
+ it("throws on HTTP error", async () => {
267
+ mockFetch.mockReturnValueOnce(err(500));
268
+ await expect(fetchTaskRuns("task-1")).rejects.toThrow("HTTP 500");
269
+ });
270
+ });
271
+
272
+ describe("fetchTaskConversations", () => {
273
+ it("returns task conversations", async () => {
274
+ const conversations = [{ id: "c1", sessionId: "task-1", promptId: "p1", agentId: 1, runId: 2, content: "log", agentModel: "claude", type: "agent_message", createdAt: "2024-01-01" }];
275
+ mockFetch.mockReturnValueOnce(ok(conversations));
276
+
277
+ const result = await fetchTaskConversations("task-1");
278
+
279
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/conversations/session/task-1"));
280
+ expect(result).toEqual(conversations);
281
+ });
282
+ });
@@ -0,0 +1,52 @@
1
+ import { SERVER_URL } from "@/lib/constants";
2
+
3
+ export type ChangeType = "added" | "modified" | "deleted" | "renamed";
4
+
5
+ export interface ChangelogFile {
6
+ id: number;
7
+ filePath: string;
8
+ changeType: ChangeType;
9
+ additions: number;
10
+ deletions: number;
11
+ patch: string | null;
12
+ }
13
+
14
+ export interface AgentChangelog {
15
+ agentId: number;
16
+ files: ChangelogFile[];
17
+ }
18
+
19
+ export interface MergeResult {
20
+ success: boolean;
21
+ message: string;
22
+ }
23
+
24
+ export async function fetchTaskChangelog(taskId: string, signal?: AbortSignal): Promise<AgentChangelog[]> {
25
+ const res = await fetch(`${SERVER_URL}/tasks/${taskId}/changelog`, { signal });
26
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
27
+ return res.json();
28
+ }
29
+
30
+ export async function fetchTaskRunChangelog(taskId: string, runId: number, signal?: AbortSignal): Promise<AgentChangelog[]> {
31
+ const res = await fetch(`${SERVER_URL}/tasks/${taskId}/runs/${runId}/changelog`, { signal });
32
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
33
+ return res.json();
34
+ }
35
+
36
+ export async function mergeAgentAll(taskId: string, agentId: number): Promise<MergeResult> {
37
+ const res = await fetch(`${SERVER_URL}/tasks/${taskId}/agents/${agentId}/merge`, {
38
+ method: "POST",
39
+ });
40
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
41
+ return res.json();
42
+ }
43
+
44
+ export async function mergeAgentFile(taskId: string, agentId: number, filePath: string): Promise<MergeResult> {
45
+ const res = await fetch(`${SERVER_URL}/tasks/${taskId}/agents/${agentId}/merge-file`, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ filePath }),
49
+ });
50
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
51
+ return res.json();
52
+ }
@@ -0,0 +1,175 @@
1
+ import { SERVER_URL } from "@/lib/constants";
2
+
3
+ export type AgentRole = "frontend" | "backend" | "doc" | "operation" | "other";
4
+
5
+ export interface TaskAgentRun {
6
+ id: number;
7
+ agentId: number;
8
+ status: string;
9
+ worktreePath: string | null;
10
+ startCommitHash: string | null;
11
+ durationMs: number | null;
12
+ costUsd: number | null;
13
+ }
14
+
15
+ export interface TaskRun {
16
+ id: number;
17
+ version: number;
18
+ supplementNote: string | null;
19
+ status: string;
20
+ startedAt: string;
21
+ completedAt: string | null;
22
+ agentRuns: TaskAgentRun[];
23
+ }
24
+
25
+ export interface TaskConversation {
26
+ id: string;
27
+ sessionId: string;
28
+ promptId: string;
29
+ agentId: number | null;
30
+ runId: number | null;
31
+ content: string;
32
+ agentModel: string;
33
+ type: "user_message" | "agent_message";
34
+ createdAt: string;
35
+ }
36
+
37
+ export type TaskStatus = "pending" | "running" | "stopped" | "completed" | "error";
38
+ export type AgentStatus = "pending" | "running" | "stopped" | "completed" | "error";
39
+
40
+ export interface TaskRequirement {
41
+ id: number;
42
+ content: string;
43
+ status: string;
44
+ orderIndex: number;
45
+ }
46
+
47
+ export type AgentType = "claude" | "gemini" | "codex" | "opencode";
48
+
49
+ export interface TaskAgent {
50
+ id: number;
51
+ agentType: AgentType;
52
+ role: AgentRole;
53
+ customRole: string | null;
54
+ status: AgentStatus;
55
+ claudeSessionId?: string | null;
56
+ }
57
+
58
+ export interface Task {
59
+ id: string;
60
+ title: string;
61
+ status: TaskStatus;
62
+ workingDir: string | null;
63
+ requirements: TaskRequirement[];
64
+ agents: TaskAgent[];
65
+ createdAt: string;
66
+ updatedAt: string;
67
+ }
68
+
69
+ export interface CreateTaskPayload {
70
+ title: string;
71
+ workingDir?: string;
72
+ requirements: { content: string; orderIndex: number }[];
73
+ agents: { agentType: AgentType; role: AgentRole; customRole?: string }[];
74
+ }
75
+
76
+ export type UpdateTaskPayload = Partial<CreateTaskPayload>;
77
+
78
+ export interface AgentTestCodePreference {
79
+ agentId: number;
80
+ writeTestCode: boolean;
81
+ }
82
+
83
+ // ─── CRUD ────────────────────────────────────────────────────────────────────
84
+
85
+ export async function createTask(payload: CreateTaskPayload): Promise<Task> {
86
+ const res = await fetch(`${SERVER_URL}/tasks`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify(payload),
90
+ });
91
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
92
+ return res.json();
93
+ }
94
+
95
+ export async function fetchTasks(): Promise<Task[]> {
96
+ const res = await fetch(`${SERVER_URL}/tasks`);
97
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
98
+ return res.json();
99
+ }
100
+
101
+ export async function fetchTask(id: string): Promise<Task> {
102
+ const res = await fetch(`${SERVER_URL}/tasks/${id}`);
103
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
104
+ return res.json();
105
+ }
106
+
107
+ export async function updateTask(id: string, payload: UpdateTaskPayload): Promise<Task> {
108
+ const res = await fetch(`${SERVER_URL}/tasks/${id}`, {
109
+ method: "PATCH",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(payload),
112
+ });
113
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
114
+ return res.json();
115
+ }
116
+
117
+ export async function archiveTask(id: string): Promise<void> {
118
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/archive`, { method: "POST" });
119
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
120
+ }
121
+
122
+ export async function deleteTask(id: string): Promise<void> {
123
+ const res = await fetch(`${SERVER_URL}/tasks/${id}`, { method: "DELETE" });
124
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
125
+ }
126
+
127
+ // ─── 실행 제어 ────────────────────────────────────────────────────────────────
128
+
129
+ export async function executeTask(id: string, agentTestCodePreferences: AgentTestCodePreference[] = []): Promise<Task> {
130
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/execute`, {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ agentTestCodePreferences }),
134
+ });
135
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
136
+ return res.json();
137
+ }
138
+
139
+ export async function stopTask(id: string): Promise<Task> {
140
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/stop`, { method: "POST" });
141
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
142
+ return res.json();
143
+ }
144
+
145
+ export async function fetchTaskRuns(id: string): Promise<TaskRun[]> {
146
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/runs`);
147
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
148
+ return res.json();
149
+ }
150
+
151
+ export async function fetchTaskConversations(id: string): Promise<TaskConversation[]> {
152
+ const res = await fetch(`${SERVER_URL}/conversations/session/${id}`);
153
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
154
+ return res.json();
155
+ }
156
+
157
+ export async function rerunTask(id: string, supplementNote?: string, writeTestCode?: boolean): Promise<Task> {
158
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/rerun`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({ supplementNote, writeTestCode }),
162
+ });
163
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
164
+ return res.json();
165
+ }
166
+
167
+ export async function rerunTaskAgent(id: string, agentId: number, supplementNote?: string, writeTestCode?: boolean): Promise<Task> {
168
+ const res = await fetch(`${SERVER_URL}/tasks/${id}/agents/${agentId}/rerun`, {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json" },
171
+ body: JSON.stringify({ supplementNote, writeTestCode }),
172
+ });
173
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
174
+ return res.json();
175
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useState } from "react";
5
+
6
+ import { fetchTask } from "../api/tasks.api";
7
+ import type { Task } from "../api/tasks.api";
8
+ import { TaskDetailView } from "../ui/TaskDetailView";
9
+
10
+ interface Props {
11
+ taskId: string;
12
+ }
13
+
14
+ export function TaskDetailPageContainer({ taskId }: Props) {
15
+ const [task, setTask] = useState<Task | null>(null);
16
+ const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState<string | null>(null);
18
+
19
+ useEffect(() => {
20
+ let active = true;
21
+ setLoading(true);
22
+ setError(null);
23
+
24
+ fetchTask(taskId)
25
+ .then((nextTask) => { if (active) setTask(nextTask); })
26
+ .catch((e: Error) => { if (active) setError(e.message); })
27
+ .finally(() => { if (active) setLoading(false); });
28
+
29
+ return () => { active = false; };
30
+ }, [taskId]);
31
+
32
+ return (
33
+ <main className="min-h-screen bg-[#faf8f5] px-4 py-8 dark:bg-[#07090e]">
34
+ <div className="mx-auto mb-6 flex max-w-4xl items-center justify-between">
35
+ <div className="flex items-center gap-3">
36
+ <Link
37
+ href="/"
38
+ className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-900/[0.08] text-gray-900/30 transition-colors hover:border-gray-900/[0.15] hover:text-gray-900/60 dark:border-white/[0.08] dark:text-white/30 dark:hover:border-white/[0.15] dark:hover:text-white/60"
39
+ aria-label="홈으로 이동"
40
+ >
41
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
42
+ <path fillRule="evenodd" d="M9.78 4.22a.75.75 0 010 1.06L7.06 8l2.72 2.72a.75.75 0 11-1.06 1.06L5.47 8.53a.75.75 0 010-1.06l3.25-3.25a.75.75 0 011.06 0z" clipRule="evenodd" />
43
+ </svg>
44
+ </Link>
45
+ <div>
46
+ <p className="text-sm font-semibold text-gray-900/80 dark:text-white/80">작업 상세보기</p>
47
+ <p className="font-mono text-[11px] text-gray-900/30 dark:text-white/30">{taskId}</p>
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ {loading && (
53
+ <div className="mx-auto flex max-w-4xl flex-col gap-3">
54
+ {[0, 1, 2].map((index) => (
55
+ <div key={index} className="h-24 animate-pulse rounded-xl border border-gray-900/[0.05] bg-white/50 dark:border-white/[0.05] dark:bg-white/[0.03]" />
56
+ ))}
57
+ </div>
58
+ )}
59
+
60
+ {!loading && error && (
61
+ <p className="mx-auto max-w-4xl rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
62
+ {error}
63
+ </p>
64
+ )}
65
+
66
+ {!loading && !error && task && <TaskDetailView task={task} />}
67
+ </main>
68
+ );
69
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { extractCopyableCodeFromPatch } from "../useChangelogCodeCopy";
4
+
5
+ describe("extractCopyableCodeFromPatch", () => {
6
+ it("diff 메타데이터와 삭제 라인을 제거하고 붙여넣기 가능한 코드만 반환한다", () => {
7
+ const patch = [
8
+ "diff --git a/src/App.tsx b/src/App.tsx",
9
+ "index abc..def 100644",
10
+ "--- a/src/App.tsx",
11
+ "+++ b/src/App.tsx",
12
+ "@@ -1,4 +1,5 @@",
13
+ " import React from \"react\";",
14
+ "-const title = \"old\";",
15
+ "+const title = \"new\";",
16
+ "+const enabled = true;",
17
+ " export function App() {",
18
+ " return title;",
19
+ " }",
20
+ ].join("\n");
21
+
22
+ expect(extractCopyableCodeFromPatch(patch)).toBe([
23
+ "import React from \"react\";",
24
+ "const title = \"new\";",
25
+ "const enabled = true;",
26
+ "export function App() {",
27
+ " return title;",
28
+ "}",
29
+ ].join("\n"));
30
+ });
31
+
32
+ it("새 파일 patch는 파일 내용만 반환한다", () => {
33
+ const patch = [
34
+ "diff --git a/src/new.ts b/src/new.ts",
35
+ "new file mode 100644",
36
+ "--- /dev/null",
37
+ "+++ b/src/new.ts",
38
+ "@@ -0,0 +1,2 @@",
39
+ "+export const value = 1;",
40
+ "+export const name = \"ji\";",
41
+ ].join("\n");
42
+
43
+ expect(extractCopyableCodeFromPatch(patch)).toBe([
44
+ "export const value = 1;",
45
+ "export const name = \"ji\";",
46
+ ].join("\n"));
47
+ });
48
+ });
@@ -0,0 +1,48 @@
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { AgentChangelog } from "../../api/changelog.api";
5
+ import * as changelogApi from "../../api/changelog.api";
6
+ import { useTaskChangelog } from "../useTaskChangelog";
7
+
8
+ vi.mock("../../api/changelog.api", () => ({
9
+ fetchTaskChangelog: vi.fn(),
10
+ }));
11
+
12
+ const mockFetchTaskChangelog = vi.mocked(changelogApi.fetchTaskChangelog);
13
+
14
+ const changelog: AgentChangelog = {
15
+ agentId: 1,
16
+ files: [
17
+ { id: 1, filePath: "src/App.tsx", changeType: "modified", additions: 3, deletions: 1, patch: "@@" },
18
+ ],
19
+ };
20
+
21
+ afterEach(() => { vi.clearAllMocks(); });
22
+
23
+ describe("useTaskChangelog", () => {
24
+ it("clears changelogs and skips fetch without a task id", () => {
25
+ const { result } = renderHook(() => useTaskChangelog(null));
26
+
27
+ expect(result.current.changelogs).toEqual([]);
28
+ expect(mockFetchTaskChangelog).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("loads changelogs with an abort signal", async () => {
32
+ mockFetchTaskChangelog.mockResolvedValueOnce([changelog]);
33
+ const { result } = renderHook(() => useTaskChangelog("task-1"));
34
+
35
+ expect(result.current.loading).toBe(true);
36
+ await waitFor(() => expect(result.current.loading).toBe(false));
37
+ expect(mockFetchTaskChangelog).toHaveBeenCalledWith("task-1", expect.any(AbortSignal));
38
+ expect(result.current.changelogs).toEqual([changelog]);
39
+ });
40
+
41
+ it("stores errors from the API", async () => {
42
+ mockFetchTaskChangelog.mockRejectedValueOnce(new Error("diff failed"));
43
+ const { result } = renderHook(() => useTaskChangelog("task-1"));
44
+
45
+ await waitFor(() => expect(result.current.loading).toBe(false));
46
+ expect(result.current.error).toBe("diff failed");
47
+ });
48
+ });