@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,76 +1,60 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
stages:
|
|
54
|
-
artifacts:
|
|
55
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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("
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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("
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
49
|
-
expect(
|
|
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("
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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("
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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 =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
177
|
+
};
|
|
83
178
|
|
|
84
179
|
await expect(bootstrap()).resolves.toBeNull();
|
|
85
180
|
});
|
|
86
181
|
|
|
87
|
-
it("registers
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
globalThis
|
|
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
|
-
|
|
97
|
-
expect(
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
"
|
|
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
|
+
});
|