@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,14 +1,56 @@
1
- import { describe, expect, it } from "vitest";
1
+ import "../../components/__tests__/test-dom";
2
2
 
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";
3
7
  import {
4
8
  applyDetailEvent,
5
- extractJobDetail,
9
+ fetchJobDetail,
10
+ handleDetailSseMessage,
6
11
  matchesJobTasksStatusPath,
7
12
  REFRESH_DEBOUNCE_MS,
8
13
  shouldRefetchDetailForTaskSet,
14
+ useJobDetailWithUpdates,
9
15
  } from "../hooks/useJobDetailWithUpdates";
10
16
  import type { NormalizedJobDetail } from "../types";
11
17
 
18
+ type Listener = (event: MessageEvent<string>) => void;
19
+
20
+ class MockEventSource {
21
+ public readonly url: string;
22
+ public closed = false;
23
+ public listeners = new Map<string, Listener>();
24
+
25
+ constructor(url: string) {
26
+ this.url = url;
27
+ MockEventSource.instances.push(this);
28
+ }
29
+
30
+ addEventListener(type: string, listener: Listener): void {
31
+ this.listeners.set(type, listener);
32
+ }
33
+
34
+ close(): void {
35
+ this.closed = true;
36
+ }
37
+
38
+ emit(type: string, data: unknown): void {
39
+ const listener = this.listeners.get(type);
40
+ if (!listener) return;
41
+ listener(new MessageEvent(type, { data: JSON.stringify(data) }));
42
+ }
43
+
44
+ static instances: MockEventSource[] = [];
45
+ static reset(): void {
46
+ MockEventSource.instances = [];
47
+ }
48
+ }
49
+
50
+ function jsonResponse(body: unknown, status = 200): Response {
51
+ return new Response(JSON.stringify(body), { status });
52
+ }
53
+
12
54
  function makeDetail(jobId: string): NormalizedJobDetail {
13
55
  return {
14
56
  id: jobId,
@@ -39,79 +81,44 @@ function makeDetail(jobId: string): NormalizedJobDetail {
39
81
  };
40
82
  }
41
83
 
84
+ function makeJobWire(fields: Partial<JobWire> & { jobId: string }): JobWire {
85
+ return {
86
+ jobId: fields.jobId,
87
+ title: fields.title ?? `Title for ${fields.jobId}`,
88
+ status: fields.status ?? "pending",
89
+ progress: fields.progress ?? 0,
90
+ createdAt: fields.createdAt ?? null,
91
+ updatedAt: fields.updatedAt ?? null,
92
+ location: fields.location ?? null,
93
+ tasks: fields.tasks ?? {},
94
+ costsSummary: fields.costsSummary ?? {
95
+ totalTokens: 0,
96
+ totalInputTokens: 0,
97
+ totalOutputTokens: 0,
98
+ totalCost: 0,
99
+ totalInputCost: 0,
100
+ totalOutputCost: 0,
101
+ estimatedCost: 0,
102
+ hasEstimated: false,
103
+ unavailableCost: 0,
104
+ },
105
+ };
106
+ }
107
+
42
108
  describe("useJobDetailWithUpdates helpers", () => {
43
109
  it("filters events by jobId", () => {
44
110
  const detail = makeDetail("job-1");
45
111
  expect(applyDetailEvent(detail, {
46
112
  type: "job:updated",
47
- data: { jobId: "job-2", name: "other" },
113
+ data: makeJobWire({ jobId: "job-2", title: "other" }),
48
114
  })).toBe(detail);
49
115
  });
50
116
 
51
- it("merges task updates and recomputes progress", () => {
52
- const detail = makeDetail("job-1");
53
- const next = applyDetailEvent(detail, {
54
- type: "task:updated",
55
- data: { jobId: "job-1", taskName: "build", task: { state: "done" } },
56
- });
57
-
58
- expect(next.tasks["build"]?.state).toBe("done");
59
- expect(next.doneCount).toBe(1);
60
- expect(next.progress).toBe(50);
61
- });
62
-
63
117
  it("matches state-change paths for the active job", () => {
64
118
  expect(matchesJobTasksStatusPath("pipeline-data/current/job-1/tasks-status.json", "job-1")).toBe(true);
65
119
  expect(matchesJobTasksStatusPath("pipeline-data/current/job-2/tasks-status.json", "job-1")).toBe(false);
66
120
  });
67
121
 
68
- it("extracts wrapped job detail payloads", () => {
69
- expect(extractJobDetail({ ok: true, data: { jobId: "job-1" } })).toEqual({ jobId: "job-1" });
70
- });
71
-
72
- it("uses pipelineConfig.tasks.length as authoritative denominator", () => {
73
- const detail: NormalizedJobDetail = {
74
- ...makeDetail("job-1"),
75
- pipelineConfig: {
76
- tasks: ["a", "b", "c", "d", "e", "f"],
77
- },
78
- taskCount: 6,
79
- tasks: {
80
- a: { name: "a", state: "done", startedAt: null, endedAt: null, files: { artifacts: [], logs: [], tmp: [] } },
81
- b: { name: "b", state: "done", startedAt: null, endedAt: null, files: { artifacts: [], logs: [], tmp: [] } },
82
- c: { name: "c", state: "done", startedAt: null, endedAt: null, files: { artifacts: [], logs: [], tmp: [] } },
83
- d: { name: "d", state: "running", startedAt: null, endedAt: null, files: { artifacts: [], logs: [], tmp: [] } },
84
- },
85
- };
86
-
87
- const next = applyDetailEvent(detail, {
88
- type: "task:updated",
89
- data: { jobId: "job-1", taskName: "d", task: { state: "running" } },
90
- });
91
-
92
- expect(next.taskCount).toBe(6);
93
- expect(next.doneCount).toBe(3);
94
- expect(next.progress).toBe(50);
95
- });
96
-
97
- it("falls back to local task list length without pipelineConfig", () => {
98
- const detail: NormalizedJobDetail = {
99
- ...makeDetail("job-1"),
100
- pipelineConfig: undefined,
101
- };
102
-
103
- const next = applyDetailEvent(detail, {
104
- type: "task:updated",
105
- data: { jobId: "job-1", taskName: "build", task: { state: "done" } },
106
- });
107
-
108
- expect(next.taskCount).toBe(2);
109
- expect(next.doneCount).toBe(1);
110
- expect(next.progress).toBe(50);
111
- expect(next.progress).toBeGreaterThanOrEqual(0);
112
- expect(next.progress).toBeLessThanOrEqual(100);
113
- });
114
-
115
122
  it("refetches full detail when a job update has a different task set than pipelineConfig", () => {
116
123
  const detail: NormalizedJobDetail = {
117
124
  ...makeDetail("job-1"),
@@ -122,36 +129,342 @@ describe("useJobDetailWithUpdates helpers", () => {
122
129
 
123
130
  expect(shouldRefetchDetailForTaskSet(detail, {
124
131
  type: "job:updated",
125
- data: {
132
+ data: makeJobWire({
126
133
  jobId: "job-1",
127
134
  tasks: {
128
135
  build: { state: "done" },
129
136
  inserted: { state: "pending" },
130
137
  test: { state: "pending" },
131
138
  },
132
- },
139
+ }),
133
140
  })).toBe(true);
134
141
  });
135
142
 
136
- it("refetches full detail when a task update targets an unknown inserted task", () => {
143
+ it("exports the detail debounce constant", () => {
144
+ expect(REFRESH_DEBOUNCE_MS).toBe(200);
145
+ });
146
+ });
147
+
148
+ describe("applyDetailEvent re-export wiring (AC-11)", () => {
149
+ it("updates the displayed task state for a matching job:updated with a changed task state", () => {
150
+ // Arrange
137
151
  const detail = makeDetail("job-1");
138
152
 
139
- expect(shouldRefetchDetailForTaskSet(detail, {
140
- type: "task:updated",
141
- data: { jobId: "job-1", taskName: "inserted", task: { state: "pending" } },
142
- })).toBe(true);
153
+ // Act
154
+ const next = applyDetailEvent(detail, {
155
+ type: "job:updated",
156
+ data: makeJobWire({
157
+ jobId: "job-1",
158
+ tasks: {
159
+ build: { state: "done" },
160
+ test: { state: "running" },
161
+ },
162
+ }),
163
+ });
164
+
165
+ // Assert
166
+ expect(next.tasks["build"]?.state).toBe("done");
167
+ expect(next.tasks["test"]?.state).toBe("running");
143
168
  });
169
+ });
144
170
 
145
- it("does not refetch full detail for known task updates", () => {
146
- const detail = makeDetail("job-1");
171
+ describe("handleDetailSseMessage (AC-12)", () => {
172
+ let warnSpy: ReturnType<typeof spyOn>;
147
173
 
148
- expect(shouldRefetchDetailForTaskSet(detail, {
149
- type: "task:updated",
150
- data: { jobId: "job-1", taskName: "build", task: { state: "running" } },
151
- })).toBe(false);
174
+ beforeEach(() => {
175
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
152
176
  });
153
177
 
154
- it("exports the detail debounce constant", () => {
155
- expect(REFRESH_DEBOUNCE_MS).toBe(200);
178
+ afterEach(() => {
179
+ warnSpy.mockRestore();
180
+ });
181
+
182
+ it("schedules refetch and warns on malformed JSON without exposing an event", () => {
183
+ // Act
184
+ const result = handleDetailSseMessage("job:updated", "{not json", "job-1");
185
+
186
+ // Assert
187
+ expect(result).toEqual({ kind: "refetch" });
188
+ expect(warnSpy).toHaveBeenCalledTimes(1);
189
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
190
+ expect(message).toContain("failed to parse SSE payload");
191
+ });
192
+
193
+ it("schedules refetch and warns on a schema-invalid payload", () => {
194
+ // Act
195
+ const result = handleDetailSseMessage(
196
+ "job:updated",
197
+ JSON.stringify({ jobId: "job-1" }),
198
+ "job-1",
199
+ );
200
+
201
+ // Assert
202
+ expect(result).toEqual({ kind: "refetch" });
203
+ expect(warnSpy).toHaveBeenCalledTimes(1);
204
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
205
+ expect(message).toContain("dropping invalid SSE event");
206
+ expect(message).toContain("job:updated");
207
+ });
208
+
209
+ it("schedules refetch and warns on an unknown event type (task:updated is not in the contract)", () => {
210
+ // Act
211
+ const result = handleDetailSseMessage(
212
+ "task:updated",
213
+ JSON.stringify({ taskName: "build" }),
214
+ "job-1",
215
+ );
216
+
217
+ // Assert
218
+ expect(result).toEqual({ kind: "refetch" });
219
+ expect(warnSpy).toHaveBeenCalledTimes(1);
220
+ const [message] = warnSpy.mock.calls[0] as [string, ...unknown[]];
221
+ expect(message).toContain("dropping invalid SSE event");
222
+ expect(message).toContain("task:updated");
223
+ });
224
+
225
+ it("schedules refetch for a state:change targeting the active job", () => {
226
+ // Act
227
+ const result = handleDetailSseMessage(
228
+ "state:change",
229
+ JSON.stringify({
230
+ jobId: "job-1",
231
+ path: "pipeline-data/current/job-1/tasks-status.json",
232
+ }),
233
+ "job-1",
234
+ );
235
+
236
+ // Assert
237
+ expect(result).toEqual({ kind: "refetch" });
238
+ expect(warnSpy).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it("skips a state:change that targets a different job", () => {
242
+ // Act
243
+ const result = handleDetailSseMessage(
244
+ "state:change",
245
+ JSON.stringify({
246
+ jobId: "job-2",
247
+ path: "pipeline-data/current/job-2/tasks-status.json",
248
+ }),
249
+ "job-1",
250
+ );
251
+
252
+ // Assert
253
+ expect(result).toEqual({ kind: "skip" });
254
+ expect(warnSpy).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it("returns an apply action for a valid job:updated event without warning", () => {
258
+ // Arrange
259
+ const wire = makeJobWire({ jobId: "job-1", title: "My Job" });
260
+
261
+ // Act
262
+ const result = handleDetailSseMessage(
263
+ "job:updated",
264
+ JSON.stringify(wire),
265
+ "job-1",
266
+ );
267
+
268
+ // Assert
269
+ expect(result.kind).toBe("apply");
270
+ if (result.kind !== "apply") throw new Error("expected apply");
271
+ expect(result.event.type).toBe("job:updated");
272
+ expect(result.event.data).toEqual(wire);
273
+ expect(warnSpy).not.toHaveBeenCalled();
274
+ });
275
+ });
276
+
277
+ describe("fetchJobDetail", () => {
278
+ let fetchSpy: ReturnType<typeof spyOn>;
279
+
280
+ beforeEach(() => {
281
+ fetchSpy = spyOn(globalThis, "fetch");
282
+ });
283
+
284
+ afterEach(() => {
285
+ fetchSpy.mockRestore();
286
+ });
287
+
288
+ it("returns adapted detail on a 2xx {ok:true} envelope", async () => {
289
+ fetchSpy.mockResolvedValue(jsonResponse({
290
+ ok: true,
291
+ data: { jobId: "job-1", tasks: {} },
292
+ }));
293
+
294
+ const detail = await fetchJobDetail("job-1", new AbortController().signal);
295
+
296
+ expect(detail.jobId).toBe("job-1");
297
+ expect(detail.tasks).toEqual({});
298
+ });
299
+
300
+ it("rejects with malformed_response on a 2xx {ok:false} envelope", async () => {
301
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: false }));
302
+
303
+ await expect(fetchJobDetail("job-1", new AbortController().signal)).rejects.toMatchObject({
304
+ code: "malformed_response",
305
+ message: "Malformed job detail response",
306
+ status: 200,
307
+ });
308
+ });
309
+
310
+ it("rejects with typed ApiError on a non-2xx response", async () => {
311
+ fetchSpy.mockResolvedValue(new Response("", { status: 404 }));
312
+
313
+ await expect(fetchJobDetail("job-1", new AbortController().signal)).rejects.toMatchObject({
314
+ code: "job_not_found",
315
+ status: 404,
316
+ });
317
+ });
318
+
319
+ it("propagates AbortError when fetch rejects with one", async () => {
320
+ fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError"));
321
+
322
+ const promise = fetchJobDetail("job-1", new AbortController().signal);
323
+ await expect(promise).rejects.toBeInstanceOf(DOMException);
324
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
156
325
  });
157
326
  });
327
+
328
+ describe("useJobDetailWithUpdates hook (AC-10)", () => {
329
+ const originalEventSource = globalThis.EventSource;
330
+ let fetchSpy: ReturnType<typeof spyOn>;
331
+
332
+ beforeEach(() => {
333
+ fetchSpy = spyOn(globalThis, "fetch");
334
+ MockEventSource.reset();
335
+ globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
336
+ });
337
+
338
+ afterEach(() => {
339
+ fetchSpy.mockRestore();
340
+ globalThis.EventSource = originalEventSource;
341
+ MockEventSource.reset();
342
+ });
343
+
344
+ it("sets typed error and leaves data null on an initial 404", async () => {
345
+ fetchSpy.mockResolvedValue(new Response("", { status: 404 }));
346
+
347
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
348
+
349
+ await waitFor(() => expect(result.current.loading).toBe(false));
350
+ expect(result.current.error).toMatchObject({
351
+ code: "job_not_found",
352
+ status: 404,
353
+ });
354
+ expect(result.current.data).toBeNull();
355
+ expect(result.current.isHydrated).toBe(false);
356
+ });
357
+
358
+ it("sets typed error and leaves data null on an initial {ok:false} envelope", async () => {
359
+ fetchSpy.mockResolvedValue(jsonResponse({ ok: false }));
360
+
361
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
362
+
363
+ await waitFor(() => expect(result.current.loading).toBe(false));
364
+ expect(result.current.error?.code).toBe("malformed_response");
365
+ expect(result.current.data).toBeNull();
366
+ });
367
+
368
+ it("populates data and clears any prior error on a valid response", async () => {
369
+ fetchSpy.mockResolvedValue(jsonResponse({
370
+ ok: true,
371
+ data: { jobId: "job-1", tasks: {} },
372
+ }));
373
+
374
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
375
+
376
+ await waitFor(() => expect(result.current.data).not.toBeNull());
377
+ expect(result.current.data?.jobId).toBe("job-1");
378
+ expect(result.current.error).toBeNull();
379
+ expect(result.current.isHydrated).toBe(true);
380
+ });
381
+
382
+ it("keeps prior data and sets typed error on a failed refetch", async () => {
383
+ fetchSpy
384
+ .mockResolvedValueOnce(jsonResponse({ ok: true, data: { jobId: "job-1", tasks: {} } }))
385
+ .mockResolvedValueOnce(new Response("", { status: 500 }));
386
+
387
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
388
+
389
+ await waitFor(() => expect(result.current.data?.jobId).toBe("job-1"));
390
+
391
+ act(() => {
392
+ result.current.refetch();
393
+ });
394
+
395
+ await waitFor(() => expect(result.current.error?.code).toBe("unknown_error"));
396
+ expect(result.current.data?.jobId).toBe("job-1");
397
+ expect(result.current.loading).toBe(false);
398
+ expect(result.current.isRefreshing).toBe(false);
399
+ });
400
+
401
+ it("ignores AbortError on initial load (no error, no data)", async () => {
402
+ fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError"));
403
+
404
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
405
+
406
+ await waitFor(() => expect(result.current.loading).toBe(false));
407
+ expect(result.current.error).toBeNull();
408
+ expect(result.current.data).toBeNull();
409
+ });
410
+
411
+ it("refetch() triggers another load and clears the error on success", async () => {
412
+ fetchSpy
413
+ .mockResolvedValueOnce(new Response("", { status: 500 }))
414
+ .mockResolvedValueOnce(jsonResponse({ ok: true, data: { jobId: "job-1", tasks: {} } }));
415
+
416
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
417
+
418
+ await waitFor(() => expect(result.current.error?.code).toBe("unknown_error"));
419
+
420
+ act(() => {
421
+ result.current.refetch();
422
+ });
423
+
424
+ await waitFor(() => expect(result.current.data?.jobId).toBe("job-1"));
425
+ expect(result.current.error).toBeNull();
426
+ expect(result.current.isHydrated).toBe(true);
427
+ });
428
+
429
+ it("keeps the EventSource subscription active and applies job:updated after a fetch error (AC-10)", async () => {
430
+ fetchSpy
431
+ .mockResolvedValueOnce(jsonResponse({
432
+ ok: true,
433
+ data: {
434
+ jobId: "job-1",
435
+ tasks: {
436
+ build: { state: "pending" },
437
+ test: { state: "pending" },
438
+ },
439
+ },
440
+ }))
441
+ .mockResolvedValueOnce(new Response("", { status: 500 }));
442
+
443
+ const { result } = renderHook(() => useJobDetailWithUpdates("job-1"));
444
+
445
+ await waitFor(() => expect(result.current.data?.jobId).toBe("job-1"));
446
+ expect(MockEventSource.instances[0]?.closed).toBe(false);
447
+
448
+ act(() => {
449
+ result.current.refetch();
450
+ });
451
+
452
+ await waitFor(() => expect(result.current.error?.code).toBe("unknown_error"));
453
+ expect(result.current.data?.tasks["build"]?.state).toBe("pending");
454
+ expect(MockEventSource.instances[0]?.closed).toBe(false);
455
+
456
+ act(() => {
457
+ MockEventSource.instances[0]?.emit("job:updated", makeJobWire({
458
+ jobId: "job-1",
459
+ tasks: {
460
+ build: { state: "running" },
461
+ test: { state: "pending" },
462
+ },
463
+ }));
464
+ });
465
+
466
+ await waitFor(() => expect(result.current.data?.tasks["build"]?.state).toBe("running"));
467
+ expect(result.current.error?.code).toBe("unknown_error");
468
+ expect(MockEventSource.instances[0]?.closed).toBe(false);
469
+ });
470
+ });