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