@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.
- package/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- package/src/ui/server/__tests__/path-containment.test.ts +0 -54
|
@@ -1,14 +1,56 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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",
|
|
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("
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
171
|
+
describe("handleDetailSseMessage (AC-12)", () => {
|
|
172
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
147
173
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
+
});
|