@ryanfw/prompt-orchestration-pipeline 1.3.2 → 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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  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/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,76 +1,60 @@
1
- export interface SourceLocation {
2
- line: number;
3
- column: number;
4
- }
5
-
6
- export interface Stage {
7
- name: string;
8
- order: number;
9
- isAsync: boolean;
10
- }
11
-
12
- export interface ArtifactRead {
13
- fileName: string;
14
- stage: string;
15
- required: boolean;
16
- }
1
+ import { z } from "zod";
17
2
 
18
- export interface ArtifactWrite {
19
- fileName: string;
20
- stage: string;
21
- }
22
-
23
- export interface UnresolvedRead {
24
- expression: string;
25
- codeContext: string;
26
- stage: string;
27
- required: boolean;
28
- location: SourceLocation;
29
- }
30
-
31
- export interface UnresolvedWrite {
32
- expression: string;
33
- codeContext: string;
34
- stage: string;
35
- location: SourceLocation;
36
- }
37
-
38
- export interface ModelCall {
39
- provider: string;
40
- method: string;
41
- stage: string;
42
- }
43
-
44
- export interface ArtifactData {
45
- reads: ArtifactRead[];
46
- writes: ArtifactWrite[];
47
- unresolvedReads: UnresolvedRead[];
48
- unresolvedWrites: UnresolvedWrite[];
49
- }
3
+ export type AnalysisStatus = "ok" | "unconfigured" | "error";
50
4
 
51
5
  export interface TaskAnalysis {
52
- taskFilePath: string | null;
53
- stages: Stage[];
54
- artifacts: ArtifactData;
55
- models: ModelCall[];
6
+ summary: string;
7
+ stages: { name: string; purpose: string }[];
8
+ artifacts: {
9
+ reads: { fileName: string; role: string }[];
10
+ writes: { fileName: string; role: string }[];
11
+ };
12
+ models: { provider: string; method: string; stage: string }[];
56
13
  }
57
14
 
58
15
  export interface PersistedTaskAnalysis extends TaskAnalysis {
59
16
  analyzedAt: string;
17
+ analysisStatus: AnalysisStatus;
18
+ analysisError?: string;
60
19
  }
61
20
 
21
+ export const TaskAnalysisSchema = z.object({
22
+ summary: z.string(),
23
+ stages: z.array(
24
+ z.object({
25
+ name: z.string(),
26
+ purpose: z.string(),
27
+ }),
28
+ ),
29
+ artifacts: z.object({
30
+ reads: z.array(
31
+ z.object({
32
+ fileName: z.string(),
33
+ role: z.string(),
34
+ }),
35
+ ),
36
+ writes: z.array(
37
+ z.object({
38
+ fileName: z.string(),
39
+ role: z.string(),
40
+ }),
41
+ ),
42
+ }),
43
+ models: z.array(
44
+ z.object({
45
+ provider: z.string(),
46
+ method: z.string(),
47
+ stage: z.string(),
48
+ }),
49
+ ),
50
+ });
51
+
62
52
  export interface DeducedSchema {
63
53
  schema: Record<string, unknown>;
64
54
  example: unknown;
65
55
  reasoning: string;
66
56
  }
67
57
 
68
- export interface ArtifactResolution {
69
- resolvedFileName: string | null;
70
- confidence: number;
71
- reasoning: string;
72
- }
73
-
74
58
  export interface ArtifactDescriptor {
75
59
  fileName: string;
76
60
  stage: string;
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { decideGate, fetchConcurrencyStatus, restartJob, startTask, stopJob } from "../api";
3
+ import { decideGate, fetchConcurrencyStatus, getJson, restartJob, startTask, stopJob } from "../api";
4
4
  import type { JobConcurrencyApiStatus } from "../types";
5
5
 
6
6
  const fetchMock = vi.fn<typeof fetch>();
@@ -177,4 +177,146 @@ describe("ui client api", () => {
177
177
  expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal);
178
178
  });
179
179
  });
180
+
181
+ describe("getJson", () => {
182
+ it("returns unwrapped data on a 2xx {ok:true} response", async () => {
183
+ fetchMock.mockResolvedValue(
184
+ new Response(JSON.stringify({ ok: true, data: { foo: "bar" } }), { status: 200 }),
185
+ );
186
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
187
+
188
+ await expect(getJson<{ foo: string }>("/api/example")).resolves.toEqual({ foo: "bar" });
189
+ });
190
+
191
+ it("uses a provided validate guard as a type predicate", async () => {
192
+ fetchMock.mockResolvedValue(
193
+ new Response(JSON.stringify({ ok: true, data: ["a", "b"] }), { status: 200 }),
194
+ );
195
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
196
+
197
+ const isStringArray = (value: unknown): value is string[] =>
198
+ Array.isArray(value) && value.every((entry) => typeof entry === "string");
199
+
200
+ await expect(getJson<string[]>("/api/lines", { validate: isStringArray })).resolves.toEqual([
201
+ "a",
202
+ "b",
203
+ ]);
204
+ });
205
+
206
+ it("throws a normalized ApiError on a non-2xx with a backend code", async () => {
207
+ fetchMock.mockResolvedValue(
208
+ new Response(JSON.stringify({ code: "job_running", message: "still running" }), {
209
+ status: 409,
210
+ }),
211
+ );
212
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
213
+
214
+ await expect(getJson("/api/jobs/x/restart")).rejects.toMatchObject({
215
+ code: "job_running",
216
+ message: "still running",
217
+ status: 409,
218
+ });
219
+ });
220
+
221
+ it("falls back to the status default message on a non-2xx without a backend message", async () => {
222
+ fetchMock.mockResolvedValue(new Response("", { status: 500 }));
223
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
224
+
225
+ await expect(getJson("/api/example")).rejects.toMatchObject({
226
+ code: "unknown_error",
227
+ message: "The server failed to process the request",
228
+ status: 500,
229
+ });
230
+ });
231
+
232
+ it("throws malformed_response on a 2xx {ok:false} envelope", async () => {
233
+ fetchMock.mockResolvedValue(
234
+ new Response(JSON.stringify({ ok: false, error: "nope" }), { status: 200 }),
235
+ );
236
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
237
+
238
+ await expect(
239
+ getJson("/api/example", { malformedMessage: "Custom malformed message" }),
240
+ ).rejects.toMatchObject({
241
+ code: "malformed_response",
242
+ message: "Custom malformed message",
243
+ status: 200,
244
+ });
245
+ });
246
+
247
+ it("throws malformed_response on a 2xx envelope missing data", async () => {
248
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
249
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
250
+
251
+ await expect(getJson("/api/example")).rejects.toMatchObject({
252
+ code: "malformed_response",
253
+ status: 200,
254
+ });
255
+ });
256
+
257
+ it("throws malformed_response when validate rejects the data shape", async () => {
258
+ fetchMock.mockResolvedValue(
259
+ new Response(JSON.stringify({ ok: true, data: 42 }), { status: 200 }),
260
+ );
261
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
262
+
263
+ await expect(
264
+ getJson("/api/lines", { validate: Array.isArray as (value: unknown) => value is unknown[] }),
265
+ ).rejects.toMatchObject({ code: "malformed_response", status: 200 });
266
+ });
267
+
268
+ it("maps a fetch rejection to network_error", async () => {
269
+ fetchMock.mockRejectedValue(new Error("offline"));
270
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
271
+
272
+ await expect(getJson("/api/example")).rejects.toMatchObject({
273
+ code: "network_error",
274
+ message: "offline",
275
+ });
276
+ });
277
+
278
+ it("propagates AbortError unchanged when fetch rejects with one", async () => {
279
+ fetchMock.mockRejectedValue(new DOMException("Aborted", "AbortError"));
280
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
281
+
282
+ const promise = getJson("/api/example");
283
+ await expect(promise).rejects.toBeInstanceOf(DOMException);
284
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
285
+ });
286
+
287
+ it("throws AbortError before fetching when the signal is already aborted", async () => {
288
+ const controller = new AbortController();
289
+ controller.abort();
290
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
291
+
292
+ const promise = getJson("/api/example", { signal: controller.signal });
293
+ await expect(promise).rejects.toBeInstanceOf(DOMException);
294
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
295
+ expect(fetchMock).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it("parses a concurrency fixture through the shared getJson path", async () => {
299
+ const sampleStatus: JobConcurrencyApiStatus = {
300
+ limit: 3,
301
+ runningCount: 1,
302
+ availableSlots: 2,
303
+ queuedCount: 1,
304
+ activeJobs: [
305
+ { jobId: "job-1", pid: 1234, acquiredAt: "2024-01-01T00:00:00Z", source: "orchestrator" },
306
+ ],
307
+ queuedJobs: [
308
+ { jobId: "job-2", queuedAt: "2024-01-01T00:00:01Z", name: "second", pipeline: "demo" },
309
+ ],
310
+ staleSlots: [],
311
+ };
312
+ fetchMock.mockResolvedValue(
313
+ new Response(JSON.stringify({ ok: true, data: sampleStatus }), { status: 200 }),
314
+ );
315
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
316
+
317
+ await expect(getJson<JobConcurrencyApiStatus>("/api/concurrency")).resolves.toEqual(
318
+ sampleStatus,
319
+ );
320
+ });
321
+ });
180
322
  });
@@ -1,106 +1,222 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, describe, expect, it, spyOn } from "bun:test";
2
2
 
3
3
  import { bootstrap } from "../bootstrap";
4
4
 
5
5
  const originalFetch = globalThis.fetch;
6
- const originalEventSource = globalThis.EventSource;
6
+ const originalEventSource = (globalThis as { EventSource?: unknown }).EventSource;
7
+ const originalWarn = console.warn;
8
+
9
+ type RecordedListener = [name: string, listener: EventListener];
7
10
 
8
11
  class MockEventSource {
9
- public readonly addEventListener = vi.fn();
12
+ public static instances: MockEventSource[] = [];
13
+ public readonly addEventListenerCalls: RecordedListener[] = [];
14
+
15
+ public readonly addEventListener = (name: string, listener: EventListener): void => {
16
+ this.addEventListenerCalls.push([name, listener]);
17
+ };
18
+
19
+ constructor(public readonly url: string) {
20
+ MockEventSource.instances.push(this);
21
+ }
22
+ }
10
23
 
11
- constructor(public readonly url: string) {}
24
+ function makeResponse(body: string, status = 200): Response {
25
+ return new Response(body, { status });
12
26
  }
13
27
 
28
+ afterEach(() => {
29
+ globalThis.fetch = originalFetch;
30
+ if (originalEventSource === undefined) {
31
+ delete (globalThis as { EventSource?: unknown }).EventSource;
32
+ } else {
33
+ (globalThis as { EventSource?: unknown }).EventSource = originalEventSource;
34
+ }
35
+ console.warn = originalWarn;
36
+ MockEventSource.instances.length = 0;
37
+ });
38
+
14
39
  describe("bootstrap", () => {
15
- afterEach(() => {
16
- globalThis.fetch = originalFetch;
17
- globalThis.EventSource = originalEventSource;
18
- vi.restoreAllMocks();
40
+ it("applies the snapshot before opening the event source", async () => {
41
+ let callOrder = 0;
42
+ let snapshotOrder = 0;
43
+ let eventSourceOrder = 0;
44
+ const applySnapshot = (_snapshot: unknown): Promise<void> => {
45
+ callOrder += 1;
46
+ snapshotOrder = callOrder;
47
+ return Promise.resolve();
48
+ };
49
+ (globalThis as { EventSource?: unknown }).EventSource = function CountingEventSource(
50
+ this: unknown,
51
+ url: string,
52
+ ): MockEventSource {
53
+ callOrder += 1;
54
+ eventSourceOrder = callOrder;
55
+ void this;
56
+ void url;
57
+ return new MockEventSource(url);
58
+ };
59
+ globalThis.fetch = (() =>
60
+ Promise.resolve(
61
+ makeResponse(JSON.stringify({ ok: true, data: { state: "ready" } })),
62
+ )) as unknown as typeof fetch;
63
+
64
+ await bootstrap({ applySnapshot });
65
+
66
+ expect(snapshotOrder).toBe(1);
67
+ expect(eventSourceOrder).toBe(2);
68
+ expect(snapshotOrder).toBeLessThan(eventSourceOrder);
19
69
  });
20
70
 
21
- it("applies the snapshot before opening the event source", async () => {
22
- const applySnapshot = vi.fn(async () => undefined);
23
- const eventSourceCtor = vi.fn((url: string) => new MockEventSource(url));
24
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
25
- new Response(JSON.stringify({ ok: true }), { status: 200 }),
26
- ) as unknown as typeof fetch;
27
- globalThis.EventSource = eventSourceCtor as unknown as typeof EventSource;
71
+ it("passes the unwrapped snapshot data to applySnapshot on a 2xx {ok:true} response", async () => {
72
+ const calls: Array<[unknown]> = [];
73
+ const applySnapshot = (snapshot: unknown) => {
74
+ calls.push([snapshot]);
75
+ };
76
+ globalThis.fetch = (() =>
77
+ Promise.resolve(
78
+ makeResponse(JSON.stringify({ ok: true, data: { state: "ready", jobs: [] } })),
79
+ )) as unknown as typeof fetch;
80
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
28
81
 
29
82
  await bootstrap({ applySnapshot });
30
83
 
31
- expect(applySnapshot).toHaveBeenCalledWith({ ok: true });
32
- expect(eventSourceCtor).toHaveBeenCalledTimes(1);
33
- const applyOrder = applySnapshot.mock.invocationCallOrder[0];
34
- const eventSourceOrder = eventSourceCtor.mock.invocationCallOrder[0];
35
- expect(applyOrder).toBeDefined();
36
- expect(eventSourceOrder).toBeDefined();
37
- expect(applyOrder!).toBeLessThan(eventSourceOrder!);
84
+ expect(calls).toEqual([[{ state: "ready", jobs: [] }]]);
38
85
  });
39
86
 
40
- it("calls applySnapshot(null) on fetch failure and still returns an event source", async () => {
41
- const applySnapshot = vi.fn();
42
- const instance = new MockEventSource("/api/events");
43
- globalThis.fetch = vi.fn<typeof fetch>().mockRejectedValue(new Error("offline")) as unknown as typeof fetch;
44
- globalThis.EventSource = vi.fn(() => instance) as unknown as typeof EventSource;
87
+ it("degrades to applySnapshot(null) and warns once on a non-2xx response", async () => {
88
+ const calls: Array<[unknown]> = [];
89
+ const applySnapshot = (snapshot: unknown) => {
90
+ calls.push([snapshot]);
91
+ };
92
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => undefined);
93
+ globalThis.fetch = (() =>
94
+ Promise.resolve(makeResponse(JSON.stringify({ error: "bad" }), 500))) as unknown as typeof fetch;
95
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
45
96
 
46
97
  const result = await bootstrap({ applySnapshot });
47
98
 
48
- expect(applySnapshot).toHaveBeenCalledWith(null);
49
- expect(result).toBe(instance);
99
+ expect(calls).toEqual([[null]]);
100
+ expect(warnSpy).toHaveBeenCalledTimes(1);
101
+ expect(warnSpy).toHaveBeenCalledWith(
102
+ "bootstrap: failed to load initial state",
103
+ expect.objectContaining({ code: "unknown_error", status: 500 }),
104
+ );
105
+ expect(result).toBeInstanceOf(MockEventSource);
50
106
  });
51
107
 
52
- it("passes through non-ok json responses", async () => {
53
- const applySnapshot = vi.fn();
54
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
55
- new Response(JSON.stringify({ error: "bad" }), { status: 500 }),
56
- ) as unknown as typeof fetch;
57
- globalThis.EventSource = vi.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
108
+ it("degrades to applySnapshot(null) and warns once on a 2xx malformed envelope", async () => {
109
+ const calls: Array<[unknown]> = [];
110
+ const applySnapshot = (snapshot: unknown) => {
111
+ calls.push([snapshot]);
112
+ };
113
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => undefined);
114
+ globalThis.fetch = (() =>
115
+ Promise.resolve(makeResponse(JSON.stringify({ ok: false })))) as unknown as typeof fetch;
116
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
58
117
 
59
- await bootstrap({ applySnapshot });
118
+ const result = await bootstrap({ applySnapshot });
60
119
 
61
- expect(applySnapshot).toHaveBeenCalledWith({ error: "bad" });
120
+ expect(calls).toEqual([[null]]);
121
+ expect(warnSpy).toHaveBeenCalledTimes(1);
122
+ expect(warnSpy).toHaveBeenCalledWith(
123
+ "bootstrap: failed to load initial state",
124
+ expect.objectContaining({ code: "malformed_response" }),
125
+ );
126
+ expect(result).toBeInstanceOf(MockEventSource);
62
127
  });
63
128
 
64
- it("passes null for unparseable responses", async () => {
65
- const applySnapshot = vi.fn();
66
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
67
- new Response("oops", { status: 500 }),
68
- ) as unknown as typeof fetch;
69
- globalThis.EventSource = vi.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
129
+ it("degrades to applySnapshot(null) and warns once on a fetch rejection", async () => {
130
+ const calls: Array<[unknown]> = [];
131
+ const applySnapshot = (snapshot: unknown) => {
132
+ calls.push([snapshot]);
133
+ };
134
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => undefined);
135
+ globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch;
136
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
70
137
 
71
- await bootstrap({ applySnapshot });
138
+ const result = await bootstrap({ applySnapshot });
72
139
 
73
- expect(applySnapshot).toHaveBeenCalledWith(null);
140
+ expect(calls).toEqual([[null]]);
141
+ expect(warnSpy).toHaveBeenCalledTimes(1);
142
+ expect(warnSpy).toHaveBeenCalledWith(
143
+ "bootstrap: failed to load initial state",
144
+ expect.objectContaining({ code: "network_error", message: "offline" }),
145
+ );
146
+ expect(result).toBeInstanceOf(MockEventSource);
147
+ });
148
+
149
+ it("degrades to applySnapshot(null) without warning on an unparseable non-2xx body", async () => {
150
+ const calls: Array<[unknown]> = [];
151
+ const applySnapshot = (snapshot: unknown) => {
152
+ calls.push([snapshot]);
153
+ };
154
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => undefined);
155
+ globalThis.fetch = (() =>
156
+ Promise.resolve(makeResponse("oops", 500))) as unknown as typeof fetch;
157
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
158
+
159
+ const result = await bootstrap({ applySnapshot });
160
+
161
+ expect(calls).toEqual([[null]]);
162
+ expect(warnSpy).toHaveBeenCalledTimes(1);
163
+ expect(warnSpy).toHaveBeenCalledWith(
164
+ "bootstrap: failed to load initial state",
165
+ expect.objectContaining({ code: "unknown_error", status: 500 }),
166
+ );
167
+ expect(result).toBeInstanceOf(MockEventSource);
74
168
  });
75
169
 
76
170
  it("returns null when event source construction fails", async () => {
77
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
78
- new Response(JSON.stringify({ ok: true }), { status: 200 }),
79
- ) as unknown as typeof fetch;
80
- globalThis.EventSource = vi.fn(() => {
171
+ globalThis.fetch = (() =>
172
+ Promise.resolve(
173
+ makeResponse(JSON.stringify({ ok: true, data: { state: "ready" } })),
174
+ )) as unknown as typeof fetch;
175
+ (globalThis as { EventSource?: unknown }).EventSource = function ThrowingEventSource(): never {
81
176
  throw new Error("boom");
82
- }) as unknown as typeof EventSource;
177
+ };
83
178
 
84
179
  await expect(bootstrap()).resolves.toBeNull();
85
180
  });
86
181
 
87
- it("registers all bootstrap event listeners", async () => {
88
- const instance = new MockEventSource("/api/events");
89
- globalThis.fetch = vi.fn<typeof fetch>().mockResolvedValue(
90
- new Response(JSON.stringify({ ok: true }), { status: 200 }),
91
- ) as unknown as typeof fetch;
92
- globalThis.EventSource = vi.fn(() => instance) as unknown as typeof EventSource;
182
+ it("registers exactly the seven contract event names", async () => {
183
+ globalThis.fetch = (() =>
184
+ Promise.resolve(
185
+ makeResponse(JSON.stringify({ ok: true, data: { state: "ready" } })),
186
+ )) as unknown as typeof fetch;
187
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
93
188
 
94
189
  await bootstrap();
95
190
 
96
- expect(instance.addEventListener).toHaveBeenCalledTimes(6);
97
- expect(instance.addEventListener.mock.calls.map(([name]) => name)).toEqual([
98
- "state",
99
- "job:updated",
191
+ const last = MockEventSource.instances.at(-1);
192
+ expect(last).toBeDefined();
193
+ const names = last?.addEventListenerCalls.map(([name]) => name) ?? [];
194
+ expect(last?.addEventListenerCalls).toHaveLength(7);
195
+ expect(names).toEqual([
196
+ "state:change",
197
+ "state:summary",
100
198
  "job:created",
199
+ "job:updated",
101
200
  "job:removed",
102
201
  "heartbeat",
103
- "message",
202
+ "seed:uploaded",
104
203
  ]);
105
204
  });
106
- });
205
+
206
+ it("does not register legacy browser event names", async () => {
207
+ globalThis.fetch = (() =>
208
+ Promise.resolve(
209
+ makeResponse(JSON.stringify({ ok: true, data: { state: "ready" } })),
210
+ )) as unknown as typeof fetch;
211
+ (globalThis as { EventSource?: unknown }).EventSource = MockEventSource;
212
+
213
+ await bootstrap();
214
+
215
+ const last = MockEventSource.instances.at(-1);
216
+ const names = new Set(last?.addEventListenerCalls.map(([name]) => name) ?? []);
217
+ expect(names.has("task:updated")).toBe(false);
218
+ expect(names.has("status:changed")).toBe(false);
219
+ expect(names.has("message")).toBe(false);
220
+ expect(names.has("state")).toBe(false);
221
+ });
222
+ });