@ryanfw/prompt-orchestration-pipeline 1.3.3 → 1.3.4

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 (198) hide show
  1. package/docs/http-api.md +14 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +144 -41
  4. package/src/api/index.ts +15 -83
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/paths.ts +11 -2
  17. package/src/config/pipeline-registry.ts +258 -0
  18. package/src/config/sse-events.ts +122 -0
  19. package/src/core/__tests__/agent-step.test.ts +115 -0
  20. package/src/core/__tests__/config.test.ts +517 -107
  21. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  22. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  23. package/src/core/__tests__/job-submission.test.ts +396 -0
  24. package/src/core/__tests__/job-view.test.ts +485 -3
  25. package/src/core/__tests__/json-file.test.ts +111 -0
  26. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  27. package/src/core/__tests__/logger.test.ts +22 -0
  28. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  29. package/src/core/__tests__/orchestrator.test.ts +373 -2
  30. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  31. package/src/core/__tests__/redact.test.ts +153 -0
  32. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  33. package/src/core/__tests__/seed-naming.test.ts +57 -0
  34. package/src/core/__tests__/single-derivation.test.ts +1 -1
  35. package/src/core/__tests__/task-runner.test.ts +594 -3
  36. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  37. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  38. package/src/core/agent-step.ts +4 -1
  39. package/src/core/agent-types.ts +5 -4
  40. package/src/core/config.ts +127 -234
  41. package/src/core/control.ts +3 -0
  42. package/src/core/job-concurrency.ts +50 -19
  43. package/src/core/job-submission.ts +133 -0
  44. package/src/core/job-view.ts +162 -18
  45. package/src/core/json-file.ts +45 -0
  46. package/src/core/lifecycle-policy.ts +23 -8
  47. package/src/core/logger.ts +0 -29
  48. package/src/core/orchestrator.ts +124 -70
  49. package/src/core/pipeline-runner.ts +85 -53
  50. package/src/core/redact.ts +40 -0
  51. package/src/core/runner-liveness.ts +8 -4
  52. package/src/core/seed-naming.ts +9 -0
  53. package/src/core/status-writer.ts +3 -28
  54. package/src/core/task-runner.ts +356 -319
  55. package/src/core/task-telemetry.ts +107 -0
  56. package/src/harness/__tests__/subprocess.test.ts +112 -1
  57. package/src/harness/subprocess.ts +55 -16
  58. package/src/llm/__tests__/index.test.ts +684 -33
  59. package/src/llm/index.ts +310 -67
  60. package/src/providers/__tests__/alibaba.test.ts +67 -6
  61. package/src/providers/__tests__/anthropic.test.ts +35 -14
  62. package/src/providers/__tests__/base.test.ts +62 -0
  63. package/src/providers/__tests__/claude-code.test.ts +99 -14
  64. package/src/providers/__tests__/deepseek.test.ts +16 -6
  65. package/src/providers/__tests__/gemini.test.ts +47 -25
  66. package/src/providers/__tests__/moonshot.test.ts +27 -0
  67. package/src/providers/__tests__/openai.test.ts +262 -74
  68. package/src/providers/__tests__/opencode.test.ts +77 -0
  69. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  70. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  71. package/src/providers/__tests__/types.test.ts +85 -0
  72. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  73. package/src/providers/__tests__/zhipu.test.ts +27 -14
  74. package/src/providers/alibaba.ts +20 -39
  75. package/src/providers/anthropic.ts +23 -11
  76. package/src/providers/base.ts +19 -0
  77. package/src/providers/claude-code.ts +27 -18
  78. package/src/providers/deepseek.ts +9 -28
  79. package/src/providers/gemini.ts +20 -58
  80. package/src/providers/moonshot.ts +15 -11
  81. package/src/providers/openai.ts +79 -61
  82. package/src/providers/opencode.ts +16 -33
  83. package/src/providers/stream-accumulator.ts +27 -21
  84. package/src/providers/types.ts +29 -4
  85. package/src/providers/zhipu.ts +15 -44
  86. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  87. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  88. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  89. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  90. package/src/task-analysis/__tests__/types.test.ts +63 -130
  91. package/src/task-analysis/analyzer.ts +91 -0
  92. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  93. package/src/task-analysis/index.ts +2 -36
  94. package/src/task-analysis/repository.ts +45 -0
  95. package/src/task-analysis/types.ts +42 -58
  96. package/src/ui/client/__tests__/api.test.ts +143 -1
  97. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  98. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  99. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  100. package/src/ui/client/__tests__/types.test.ts +66 -3
  101. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  102. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  103. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  104. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  105. package/src/ui/client/adapters/job-adapter.ts +31 -16
  106. package/src/ui/client/api.ts +38 -15
  107. package/src/ui/client/bootstrap.ts +19 -14
  108. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  109. package/src/ui/client/hooks/useJobList.ts +26 -31
  110. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  111. package/src/ui/client/load-state.ts +20 -0
  112. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  113. package/src/ui/client/reducers/job-events.ts +137 -0
  114. package/src/ui/client/types.ts +14 -20
  115. package/src/ui/components/DAGGrid.tsx +6 -1
  116. package/src/ui/components/JobDetail.tsx +12 -4
  117. package/src/ui/components/JobTable.tsx +13 -2
  118. package/src/ui/components/StageTimeline.tsx +8 -20
  119. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  120. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  121. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  122. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  123. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  124. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  125. package/src/ui/components/types.ts +33 -15
  126. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
  127. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  128. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  129. package/src/ui/dist/index.html +2 -2
  130. package/src/ui/pages/PipelineDetail.tsx +60 -4
  131. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  132. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  133. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  134. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  135. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  136. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  137. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  138. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  139. package/src/ui/server/__tests__/index.test.ts +63 -0
  140. package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
  141. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  142. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  143. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  144. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  145. package/src/ui/server/__tests__/router.test.ts +104 -0
  146. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  147. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  148. package/src/ui/server/config-bridge-node.ts +8 -26
  149. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  150. package/src/ui/server/embedded-assets.ts +13 -2
  151. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  152. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  153. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  154. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  155. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  156. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  157. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  158. package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
  159. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  160. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  161. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  162. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  163. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  164. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  165. package/src/ui/server/index.ts +19 -14
  166. package/src/ui/server/job-reader.ts +14 -2
  167. package/src/ui/server/router.ts +33 -10
  168. package/src/ui/server/sse-broadcast.ts +14 -3
  169. package/src/ui/server/sse-enhancer.ts +12 -2
  170. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  171. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  172. package/src/ui/state/__tests__/types.test.ts +6 -0
  173. package/src/ui/state/snapshot.ts +1 -1
  174. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  175. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  176. package/src/ui/state/transformers/list-transformer.ts +6 -0
  177. package/src/ui/state/types.ts +6 -1
  178. package/src/utils/__tests__/path-containment.test.ts +160 -0
  179. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  180. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  181. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  182. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  183. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  184. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  185. package/src/task-analysis/__tests__/index.test.ts +0 -143
  186. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  187. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  188. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  189. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  190. package/src/task-analysis/extractors/artifacts.ts +0 -143
  191. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  192. package/src/task-analysis/extractors/stages.ts +0 -45
  193. package/src/task-analysis/parser.ts +0 -20
  194. package/src/task-analysis/utils/ast.ts +0 -45
  195. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  196. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  197. package/src/ui/embedded-assets.js +0 -12
  198. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,40 +1,215 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import "../../components/__tests__/test-dom";
2
2
 
3
- import { extractJobList, fetchJobList } from "../hooks/useJobList";
3
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
4
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
5
 
5
- const originalFetch = globalThis.fetch;
6
+ import { adaptJobSummary } from "../adapters/job-adapter";
7
+ import { fetchJobList, useJobList } from "../hooks/useJobList";
8
+ import type { NormalizedJobSummary } from "../types";
9
+
10
+ function jsonResponse(body: unknown, status = 200): Response {
11
+ return new Response(JSON.stringify(body), { status });
12
+ }
13
+
14
+ function makeJob(jobId: string): NormalizedJobSummary {
15
+ return {
16
+ id: jobId,
17
+ jobId,
18
+ name: jobId,
19
+ status: "pending",
20
+ progress: 0,
21
+ taskCount: 0,
22
+ doneCount: 0,
23
+ location: "current",
24
+ tasks: {},
25
+ current: null,
26
+ costsSummary: {
27
+ totalTokens: 0,
28
+ totalInputTokens: 0,
29
+ totalOutputTokens: 0,
30
+ totalCost: 0,
31
+ totalInputCost: 0,
32
+ totalOutputCost: 0,
33
+ estimatedCost: 0,
34
+ hasEstimated: false,
35
+ unavailableCost: 0,
36
+ },
37
+ totalCost: 0,
38
+ totalTokens: 0,
39
+ __warnings: [],
40
+ displayCategory: "current",
41
+ };
42
+ }
43
+
44
+ describe("fetchJobList", () => {
45
+ let fetchSpy: ReturnType<typeof spyOn>;
46
+
47
+ beforeEach(() => {
48
+ fetchSpy = spyOn(globalThis, "fetch");
49
+ });
6
50
 
7
- describe("useJobList helpers", () => {
8
51
  afterEach(() => {
9
- globalThis.fetch = originalFetch;
10
- vi.restoreAllMocks();
52
+ fetchSpy.mockRestore();
11
53
  });
12
54
 
13
- it("extracts wrapped responses", () => {
14
- expect(extractJobList({ ok: true, data: [{ jobId: "job-1" }] })).toEqual([{ jobId: "job-1" }]);
55
+ it("returns adapted jobs on a valid envelope", async () => {
56
+ fetchSpy.mockResolvedValue(jsonResponse({
57
+ ok: true,
58
+ data: [{ jobId: "job-1", tasks: {} }],
59
+ }));
60
+
61
+ const jobs = await fetchJobList();
62
+
63
+ expect(jobs).toHaveLength(1);
64
+ expect(jobs[0]?.jobId).toBe("job-1");
15
65
  });
16
66
 
17
- it("extracts bare arrays", () => {
18
- expect(extractJobList([{ jobId: "job-1" }])).toEqual([{ jobId: "job-1" }]);
67
+ it("propagates the abort signal to fetch", async () => {
68
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: true, data: [] }));
69
+
70
+ const controller = new AbortController();
71
+ await fetchJobList(controller.signal);
72
+
73
+ const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
74
+ expect(init?.signal).toBe(controller.signal);
19
75
  });
20
76
 
21
- it("fetches and adapts jobs", async () => {
22
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
23
- new Response(JSON.stringify([{ jobId: "job-1", tasks: {} }]), { status: 200 }),
24
- ) as unknown as typeof fetch;
77
+ it("rejects with network_error when fetch fails", async () => {
78
+ fetchSpy.mockRejectedValue(new Error("offline"));
25
79
 
26
- await expect(fetchJobList()).resolves.toMatchObject([{ jobId: "job-1" }]);
80
+ await expect(fetchJobList()).rejects.toMatchObject({
81
+ code: "network_error",
82
+ message: "offline",
83
+ });
27
84
  });
28
85
 
29
- it("passes abort signals through to fetch", async () => {
30
- const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
31
- new Response(JSON.stringify([]), { status: 200 }),
32
- );
33
- globalThis.fetch = fetchMock as unknown as typeof fetch;
34
- const controller = new AbortController();
86
+ it("rejects with AbortError when fetch rejects with one", async () => {
87
+ fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError"));
35
88
 
36
- await fetchJobList(controller.signal);
89
+ const promise = fetchJobList();
90
+ await expect(promise).rejects.toBeInstanceOf(DOMException);
91
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
92
+ });
37
93
 
38
- expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal);
94
+ it("rejects with typed ApiError on a non-2xx response", async () => {
95
+ fetchSpy.mockResolvedValue(new Response("", { status: 500 }));
96
+
97
+ await expect(fetchJobList()).rejects.toMatchObject({
98
+ code: "unknown_error",
99
+ status: 500,
100
+ });
101
+ });
102
+
103
+ it("rejects with malformed_response on a 2xx {ok:false} envelope", async () => {
104
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: false }));
105
+
106
+ await expect(fetchJobList()).rejects.toMatchObject({
107
+ code: "malformed_response",
108
+ status: 200,
109
+ });
110
+ });
111
+
112
+ it("rejects with malformed_response when data is not an array", async () => {
113
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: true, data: { not: "an array" } }));
114
+
115
+ await expect(fetchJobList()).rejects.toMatchObject({
116
+ code: "malformed_response",
117
+ status: 200,
118
+ });
39
119
  });
40
120
  });
121
+
122
+ describe("useJobList hook (AC-7, AC-8)", () => {
123
+ let fetchSpy: ReturnType<typeof spyOn>;
124
+
125
+ beforeEach(() => {
126
+ fetchSpy = spyOn(globalThis, "fetch");
127
+ });
128
+
129
+ afterEach(() => {
130
+ fetchSpy.mockRestore();
131
+ });
132
+
133
+ it("sets error and leaves data null on an initial 500", async () => {
134
+ fetchSpy.mockResolvedValue(new Response("", { status: 500 }));
135
+
136
+ const { result } = renderHook(() => useJobList());
137
+
138
+ await waitFor(() => expect(result.current.loading).toBe(false));
139
+ expect(result.current.error).toMatchObject({
140
+ code: "unknown_error",
141
+ status: 500,
142
+ });
143
+ expect(result.current.data).toBeNull();
144
+ });
145
+
146
+ it("sets error and leaves data null on an initial {ok:false} envelope", async () => {
147
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: false }));
148
+
149
+ const { result } = renderHook(() => useJobList());
150
+
151
+ await waitFor(() => expect(result.current.loading).toBe(false));
152
+ expect(result.current.error?.code).toBe("malformed_response");
153
+ expect(result.current.data).toBeNull();
154
+ });
155
+
156
+ it("populates data and clears any prior error on a valid response", async () => {
157
+ fetchSpy.mockResolvedValue(jsonResponse({
158
+ ok: true,
159
+ data: [{ jobId: "job-1", tasks: {} }],
160
+ }));
161
+
162
+ const { result } = renderHook(() => useJobList());
163
+
164
+ await waitFor(() => expect(result.current.data).not.toBeNull());
165
+ expect(result.current.data?.[0]?.jobId).toBe("job-1");
166
+ expect(result.current.error).toBeNull();
167
+ });
168
+
169
+ it("keeps prior data and sets error on a failed refetch", async () => {
170
+ const expected = adaptJobSummary({ jobId: "job-1", tasks: {} });
171
+ fetchSpy
172
+ .mockResolvedValueOnce(jsonResponse({ ok: true, data: [{ jobId: "job-1", tasks: {} }] }))
173
+ .mockResolvedValueOnce(new Response("", { status: 500 }));
174
+
175
+ const { result } = renderHook(() => useJobList());
176
+
177
+ await waitFor(() => expect(result.current.data?.[0]?.jobId).toBe("job-1"));
178
+
179
+ act(() => {
180
+ result.current.refetch();
181
+ });
182
+
183
+ await waitFor(() => expect(result.current.error?.code).toBe("unknown_error"));
184
+ expect(result.current.data?.[0]).toEqual(expected);
185
+ expect(result.current.loading).toBe(false);
186
+ });
187
+
188
+ it("ignores AbortError on initial load (no error, data null)", async () => {
189
+ fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError"));
190
+
191
+ const { result } = renderHook(() => useJobList());
192
+
193
+ await waitFor(() => expect(result.current.loading).toBe(false));
194
+ expect(result.current.error).toBeNull();
195
+ expect(result.current.data).toBeNull();
196
+ });
197
+
198
+ it("ignores AbortError on refetch and preserves prior data", async () => {
199
+ fetchSpy
200
+ .mockResolvedValueOnce(jsonResponse({ ok: true, data: [{ jobId: "job-1", tasks: {} }] }))
201
+ .mockRejectedValueOnce(new DOMException("Aborted", "AbortError"));
202
+
203
+ const { result } = renderHook(() => useJobList());
204
+
205
+ await waitFor(() => expect(result.current.data?.[0]?.jobId).toBe("job-1"));
206
+
207
+ act(() => {
208
+ result.current.refetch();
209
+ });
210
+
211
+ await waitFor(() => expect(result.current.loading).toBe(false));
212
+ expect(result.current.data?.[0]?.jobId).toBe("job-1");
213
+ expect(result.current.error).toBeNull();
214
+ });
215
+ });
@@ -1,8 +1,53 @@
1
- import { describe, expect, it } from "vitest";
1
+ import "../../components/__tests__/test-dom";
2
2
 
3
- import { applyJobEvent, shouldRefetchForListEvent } from "../hooks/useJobListWithUpdates";
3
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
4
+ import { act, renderHook, waitFor } from "@testing-library/react";
5
+
6
+ import type { JobWire } from "../../../config/sse-events.ts";
7
+ import {
8
+ applyJobEvent,
9
+ handleListSseMessage,
10
+ shouldRefetchForListEvent,
11
+ useJobListWithUpdates,
12
+ } from "../hooks/useJobListWithUpdates";
4
13
  import type { NormalizedJobSummary } from "../types";
5
14
 
15
+ type Listener = (event: MessageEvent<string>) => void;
16
+
17
+ class MockEventSource {
18
+ public readonly url: string;
19
+ public closed = false;
20
+ public listeners = new Map<string, Listener>();
21
+
22
+ constructor(url: string) {
23
+ this.url = url;
24
+ MockEventSource.instances.push(this);
25
+ }
26
+
27
+ addEventListener(type: string, listener: Listener): void {
28
+ this.listeners.set(type, listener);
29
+ }
30
+
31
+ close(): void {
32
+ this.closed = true;
33
+ }
34
+
35
+ emit(type: string, data: unknown): void {
36
+ const listener = this.listeners.get(type);
37
+ if (!listener) return;
38
+ listener(new MessageEvent(type, { data: JSON.stringify(data) }));
39
+ }
40
+
41
+ static instances: MockEventSource[] = [];
42
+ static reset(): void {
43
+ MockEventSource.instances = [];
44
+ }
45
+ }
46
+
47
+ function jsonResponse(body: unknown, status = 200): Response {
48
+ return new Response(JSON.stringify(body), { status });
49
+ }
50
+
6
51
  function makeJob(jobId: string, fields: Partial<NormalizedJobSummary> = {}): NormalizedJobSummary {
7
52
  return {
8
53
  id: jobId,
@@ -22,6 +67,9 @@ function makeJob(jobId: string, fields: Partial<NormalizedJobSummary> = {}): Nor
22
67
  totalCost: 0,
23
68
  totalInputCost: 0,
24
69
  totalOutputCost: 0,
70
+ estimatedCost: 0,
71
+ hasEstimated: false,
72
+ unavailableCost: 0,
25
73
  },
26
74
  totalCost: 0,
27
75
  totalTokens: 0,
@@ -31,37 +79,200 @@ function makeJob(jobId: string, fields: Partial<NormalizedJobSummary> = {}): Nor
31
79
  };
32
80
  }
33
81
 
82
+ function makeJobWire(fields: Partial<JobWire> & { jobId: string }): JobWire {
83
+ return {
84
+ jobId: fields.jobId,
85
+ title: fields.title ?? `Title for ${fields.jobId}`,
86
+ status: fields.status ?? "pending",
87
+ progress: fields.progress ?? 0,
88
+ createdAt: fields.createdAt ?? null,
89
+ updatedAt: fields.updatedAt ?? null,
90
+ location: fields.location ?? null,
91
+ tasks: fields.tasks ?? {},
92
+ costsSummary: fields.costsSummary ?? {
93
+ totalTokens: 0,
94
+ totalInputTokens: 0,
95
+ totalOutputTokens: 0,
96
+ totalCost: 0,
97
+ totalInputCost: 0,
98
+ totalOutputCost: 0,
99
+ estimatedCost: 0,
100
+ hasEstimated: false,
101
+ unavailableCost: 0,
102
+ },
103
+ };
104
+ }
105
+
34
106
  describe("useJobListWithUpdates helpers", () => {
35
107
  it("adds jobs on job:created", () => {
36
- const next = applyJobEvent([], { type: "job:created", data: { jobId: "job-1", tasks: {} } });
108
+ const next = applyJobEvent([], {
109
+ type: "job:created",
110
+ data: makeJobWire({ jobId: "job-1" }),
111
+ });
37
112
  expect(next).toHaveLength(1);
38
113
  expect(next[0]?.jobId).toBe("job-1");
39
114
  });
40
115
 
41
116
  it("removes jobs on job:removed", () => {
42
- const next = applyJobEvent([makeJob("job-1")], { type: "job:removed", data: { jobId: "job-1", tasks: {} } });
117
+ const next = applyJobEvent(
118
+ [makeJob("job-1")],
119
+ { type: "job:removed", data: { jobId: "job-1" } },
120
+ );
43
121
  expect(next).toEqual([]);
44
122
  });
45
123
 
46
124
  it("merges jobs on job:updated", () => {
47
125
  const next = applyJobEvent([makeJob("job-1", { name: "old" })], {
48
126
  type: "job:updated",
49
- data: { jobId: "job-1", name: "new", tasks: {} },
127
+ data: makeJobWire({ jobId: "job-1", title: "new" }),
50
128
  });
51
129
  expect(next[0]?.name).toBe("new");
52
130
  });
53
131
 
54
132
  it("reuses the previous reference when nothing changes", () => {
55
- const jobs = [makeJob("job-1")];
133
+ const jobs = [makeJob("job-1", { name: "same" })];
56
134
  const next = applyJobEvent(jobs, {
57
135
  type: "job:updated",
58
- data: { jobId: "job-1", tasks: {} },
136
+ data: makeJobWire({ jobId: "job-1", title: "same" }),
59
137
  });
60
138
  expect(next).toBe(jobs);
61
139
  });
62
140
 
63
141
  it("flags list refetch events", () => {
64
142
  expect(shouldRefetchForListEvent("seed:uploaded")).toBe(true);
143
+ expect(shouldRefetchForListEvent("state:change")).toBe(true);
144
+ expect(shouldRefetchForListEvent("state:summary")).toBe(true);
65
145
  expect(shouldRefetchForListEvent("job:updated")).toBe(false);
146
+ expect(shouldRefetchForListEvent("heartbeat")).toBe(false);
147
+ });
148
+ });
149
+
150
+ describe("handleListSseMessage", () => {
151
+ let warnSpy: ReturnType<typeof spyOn>;
152
+
153
+ beforeEach(() => {
154
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
155
+ });
156
+
157
+ afterEach(() => {
158
+ warnSpy.mockRestore();
159
+ });
160
+
161
+ it("schedules refetch and warns on malformed JSON without exposing an event", () => {
162
+ const result = handleListSseMessage("job:updated", "{not json");
163
+
164
+ expect(result).toEqual({ kind: "refetch" });
165
+ expect(warnSpy).toHaveBeenCalledTimes(1);
166
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
167
+ expect(message).toContain("failed to parse SSE payload");
168
+ });
169
+
170
+ it("schedules refetch and warns on a schema-invalid payload (AC-12)", () => {
171
+ const result = handleListSseMessage("job:updated", JSON.stringify({ jobId: "job-1" }));
172
+
173
+ expect(result).toEqual({ kind: "refetch" });
174
+ expect(warnSpy).toHaveBeenCalledTimes(1);
175
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
176
+ expect(message).toContain("dropping invalid SSE event");
177
+ expect(message).toContain("job:updated");
178
+ });
179
+
180
+ it("schedules refetch and warns on an unknown event type (AC-12)", () => {
181
+ const result = handleListSseMessage("task:updated", JSON.stringify({ taskName: "build" }));
182
+
183
+ expect(result).toEqual({ kind: "refetch" });
184
+ expect(warnSpy).toHaveBeenCalledTimes(1);
185
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
186
+ expect(message).toContain("dropping invalid SSE event");
187
+ expect(message).toContain("task:updated");
188
+ });
189
+
190
+ it("schedules refetch for a typed seed:uploaded event without warning", () => {
191
+ const result = handleListSseMessage("seed:uploaded", JSON.stringify({}));
192
+
193
+ expect(result).toEqual({ kind: "refetch" });
194
+ expect(warnSpy).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("schedules refetch for a typed state:summary event without warning", () => {
198
+ const result = handleListSseMessage(
199
+ "state:summary",
200
+ JSON.stringify({ changeCount: 3 }),
201
+ );
202
+
203
+ expect(result).toEqual({ kind: "refetch" });
204
+ expect(warnSpy).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("schedules refetch for a typed state:change event without warning", () => {
208
+ const result = handleListSseMessage(
209
+ "state:change",
210
+ JSON.stringify({ jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" }),
211
+ );
212
+
213
+ expect(result).toEqual({ kind: "refetch" });
214
+ expect(warnSpy).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("returns an apply action for a valid job:updated event without warning", () => {
218
+ const wire = makeJobWire({ jobId: "job-1", title: "My Job" });
219
+
220
+ const result = handleListSseMessage("job:updated", JSON.stringify(wire));
221
+
222
+ expect(result.kind).toBe("apply");
223
+ if (result.kind !== "apply") throw new Error("expected apply");
224
+ expect(result.event.type).toBe("job:updated");
225
+ expect(result.event.data).toEqual(wire);
226
+ expect(warnSpy).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("returns an apply action for a valid heartbeat event (non-list, non-refetch)", () => {
230
+ const result = handleListSseMessage(
231
+ "heartbeat",
232
+ JSON.stringify({ ok: true, timestamp: "2026-06-21T00:00:00.000Z" }),
233
+ );
234
+
235
+ expect(result.kind).toBe("apply");
236
+ if (result.kind !== "apply") throw new Error("expected apply");
237
+ expect(result.event.type).toBe("heartbeat");
238
+ expect(warnSpy).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ describe("useJobListWithUpdates wrapper (AC-9)", () => {
243
+ const originalEventSource = globalThis.EventSource;
244
+ let fetchSpy: ReturnType<typeof spyOn>;
245
+
246
+ beforeEach(() => {
247
+ fetchSpy = spyOn(globalThis, "fetch");
248
+ MockEventSource.reset();
249
+ globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
250
+ });
251
+
252
+ afterEach(() => {
253
+ fetchSpy.mockRestore();
254
+ globalThis.EventSource = originalEventSource;
255
+ MockEventSource.reset();
256
+ });
257
+
258
+ it("keeps the rendered list intact when an SSE-triggered refetch fails (AC-9)", async () => {
259
+ const firstJobs = [{ jobId: "job-1", tasks: {} }];
260
+ fetchSpy
261
+ .mockResolvedValueOnce(jsonResponse({ ok: true, data: firstJobs }))
262
+ .mockResolvedValueOnce(new Response("", { status: 500 }));
263
+
264
+ const { result } = renderHook(() => useJobListWithUpdates());
265
+
266
+ await waitFor(() => expect(result.current.data).not.toBeNull());
267
+ expect(result.current.data?.[0]?.jobId).toBe("job-1");
268
+ expect(result.current.error).toBeNull();
269
+
270
+ act(() => {
271
+ MockEventSource.instances[0]?.emit("state:summary", { changeCount: 1 });
272
+ });
273
+
274
+ await waitFor(() => expect(result.current.error?.code).toBe("unknown_error"));
275
+ expect(result.current.data?.[0]?.jobId).toBe("job-1");
276
+ expect(result.current.data?.length).toBe(1);
66
277
  });
67
278
  });
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { deriveProgress } from "../../../../core/job-view.ts";
4
+ import { adaptJobDetail, adaptJobSummary } from "../job-adapter.ts";
5
+ import { applyJobEvent } from "../../reducers/job-events.ts";
6
+ import type { JobWire, SseEvent } from "../../../../config/sse-events.ts";
7
+
8
+ function makeWire(overrides: Partial<JobWire> & { jobId: string }): JobWire {
9
+ return {
10
+ jobId: overrides.jobId,
11
+ title: overrides.title ?? `Title for ${overrides.jobId}`,
12
+ status: overrides.status ?? "pending",
13
+ progress: overrides.progress ?? 0,
14
+ createdAt: overrides.createdAt ?? null,
15
+ updatedAt: overrides.updatedAt ?? null,
16
+ location: overrides.location ?? null,
17
+ tasks: overrides.tasks ?? {},
18
+ costsSummary: overrides.costsSummary ?? {
19
+ totalTokens: 0,
20
+ totalInputTokens: 0,
21
+ totalOutputTokens: 0,
22
+ totalCost: 0,
23
+ totalInputCost: 0,
24
+ totalOutputCost: 0,
25
+ estimatedCost: 0,
26
+ hasEstimated: false,
27
+ unavailableCost: 0,
28
+ },
29
+ ...(overrides.pipelineConfig ? { pipelineConfig: overrides.pipelineConfig } : {}),
30
+ };
31
+ }
32
+
33
+ describe("job-adapter progress (AC-7)", () => {
34
+ it("adapter progress equals deriveProgress output when configured > actual", () => {
35
+ // Arrange — pipeline has 6 tasks, only 3 are reported; one is "done".
36
+ const apiJob = {
37
+ jobId: "job-1",
38
+ tasks: {
39
+ build: { state: "done" },
40
+ test: { state: "pending" },
41
+ lint: { state: "pending" },
42
+ },
43
+ pipelineConfig: {
44
+ tasks: [
45
+ { name: "build" },
46
+ { name: "test" },
47
+ { name: "lint" },
48
+ { name: "deploy" },
49
+ { name: "verify" },
50
+ { name: "publish" },
51
+ ],
52
+ },
53
+ };
54
+ const expected = deriveProgress(
55
+ [
56
+ { state: "done" },
57
+ { state: "pending" },
58
+ { state: "pending" },
59
+ ],
60
+ 6,
61
+ );
62
+
63
+ // Act
64
+ const job = adaptJobSummary(apiJob);
65
+
66
+ // Assert
67
+ expect(job.taskCount).toBe(expected.taskCount);
68
+ expect(job.doneCount).toBe(expected.doneCount);
69
+ expect(job.completedCount).toBe(expected.completedCount);
70
+ expect(job.progress).toBe(expected.progress);
71
+ });
72
+
73
+ it("adapter progress equals deriveProgress output when configured < actual", () => {
74
+ // Arrange — pipeline has 2 tasks, but 3 are reported (one extra runtime task).
75
+ const apiJob = {
76
+ jobId: "job-1",
77
+ tasks: {
78
+ build: { state: "done" },
79
+ test: { state: "pending" },
80
+ _fileTrack: { state: "pending" },
81
+ },
82
+ pipelineConfig: { tasks: [{ name: "build" }, { name: "test" }] },
83
+ };
84
+ const expected = deriveProgress(
85
+ [
86
+ { state: "done" },
87
+ { state: "pending" },
88
+ { state: "pending" },
89
+ ],
90
+ 2,
91
+ );
92
+
93
+ // Act
94
+ const job = adaptJobSummary(apiJob);
95
+
96
+ // Assert
97
+ expect(job.taskCount).toBe(expected.taskCount);
98
+ expect(job.doneCount).toBe(expected.doneCount);
99
+ expect(job.completedCount).toBe(expected.completedCount);
100
+ expect(job.progress).toBe(expected.progress);
101
+ });
102
+
103
+ it("list, detail, and adapter agree on progress for one shared fixture", () => {
104
+ // Arrange — one fixture covering done / skipped / pending with a 3-task pipelineConfig.
105
+ const apiJob = {
106
+ jobId: "job-1",
107
+ title: "Shared",
108
+ status: "running" as const,
109
+ tasks: {
110
+ build: { state: "done" },
111
+ test: { state: "skipped" },
112
+ deploy: { state: "pending" },
113
+ },
114
+ pipelineConfig: { tasks: [{ name: "build" }, { name: "test" }, { name: "deploy" }] },
115
+ };
116
+ const wire = makeWire({
117
+ jobId: "job-1",
118
+ title: "Shared",
119
+ status: "running",
120
+ tasks: {
121
+ build: { state: "done" },
122
+ test: { state: "skipped" },
123
+ deploy: { state: "pending" },
124
+ },
125
+ pipelineConfig: { tasks: [{ name: "build" }, { name: "test" }, { name: "deploy" }] },
126
+ });
127
+ const configuredCount = Array.isArray(wire.pipelineConfig?.["tasks"])
128
+ ? (wire.pipelineConfig?.["tasks"] as unknown[]).length
129
+ : null;
130
+ const expected = deriveProgress(
131
+ [
132
+ { state: "done" },
133
+ { state: "skipped" },
134
+ { state: "pending" },
135
+ ],
136
+ configuredCount,
137
+ );
138
+ const event: SseEvent = { type: "job:updated", data: wire };
139
+
140
+ // Act
141
+ const summary = adaptJobSummary(apiJob);
142
+ const detail = adaptJobDetail(apiJob);
143
+ const listAfter = applyJobEvent([], event);
144
+
145
+ // Assert — summary, detail, and the list reducer (which reuses adaptJobSummary) all agree.
146
+ expect(summary.taskCount).toBe(expected.taskCount);
147
+ expect(summary.doneCount).toBe(expected.doneCount);
148
+ expect(summary.completedCount).toBe(expected.completedCount);
149
+ expect(summary.progress).toBe(expected.progress);
150
+
151
+ expect(detail.taskCount).toBe(expected.taskCount);
152
+ expect(detail.doneCount).toBe(expected.doneCount);
153
+ expect(detail.completedCount).toBe(expected.completedCount);
154
+ expect(detail.progress).toBe(expected.progress);
155
+
156
+ expect(listAfter).toHaveLength(1);
157
+ expect(listAfter[0]?.taskCount).toBe(expected.taskCount);
158
+ expect(listAfter[0]?.doneCount).toBe(expected.doneCount);
159
+ expect(listAfter[0]?.completedCount).toBe(expected.completedCount);
160
+ expect(listAfter[0]?.progress).toBe(expected.progress);
161
+ });
162
+
163
+ it("preserves costEstimated on task cost breakdowns", () => {
164
+ const detail = adaptJobDetail({
165
+ jobId: "job-1",
166
+ tasks: { build: { state: "done" } },
167
+ costs: {
168
+ build: {
169
+ inputTokens: 10,
170
+ outputTokens: 5,
171
+ inputCost: 0,
172
+ outputCost: 0,
173
+ totalCost: 0,
174
+ source: "reported",
175
+ costEstimated: true,
176
+ },
177
+ },
178
+ });
179
+
180
+ expect(detail.costs?.build).toMatchObject({
181
+ source: "reported",
182
+ totalCost: 0,
183
+ costEstimated: true,
184
+ });
185
+ });
186
+ });