返回 CodeWhale
fleet-client.test.mjs
根目录 / npm / runtime-sdk / test / fleet-client.test.mjs
1 import assert from "node:assert/strict";
2 import test from "node:test";
3 import {
4 CodeWhaleRuntimeClient,
5 RuntimeApiError,
6 RuntimeCapabilityError,
7 createRuntimeClient,
8 } from "../index.js";
9
10 function jsonResponse(body, init = {}) {
11 return new Response(JSON.stringify(body), {
12 status: init.status ?? 200,
13 headers: { "content-type": "application/json", ...(init.headers ?? {}) },
14 });
15 }
16
17 function fakeFetch(responseFactory) {
18 const calls = [];
19 const fetch = async (url, init) => {
20 calls.push({ url: url.toString(), init });
21 return responseFactory(url, init, calls.length);
22 };
23 fetch.calls = calls;
24 return fetch;
25 }
26
27 test("createRuntimeClient returns CodeWhaleRuntimeClient instance", () => {
28 const fetch = fakeFetch(() => jsonResponse({}));
29 const client = createRuntimeClient({ fetch });
30
31 assert.ok(client instanceof CodeWhaleRuntimeClient);
32 assert.equal(client.baseUrl, "http://127.0.0.1:7878/");
33 });
34
35 test("listFleetRuns calls the Runtime API with bearer auth", async () => {
36 const fetch = fakeFetch(() =>
37 jsonResponse({
38 status: { runs: 1, workers: {} },
39 runs: [{ id: "run-1", name: "smoke", tasks: [], labels: {} }],
40 }),
41 );
42 const client = createRuntimeClient({
43 baseUrl: "http://127.0.0.1:7878",
44 token: "token-1",
45 fetch,
46 });
47
48 const response = await client.listFleetRuns();
49
50 assert.equal(response.runs[0].id, "run-1");
51 assert.equal(fetch.calls[0].url, "http://127.0.0.1:7878/v1/fleet/runs");
52 assert.equal(fetch.calls[0].init.method, "GET");
53 assert.equal(fetch.calls[0].init.headers.get("authorization"), "Bearer token-1");
54 });
55
56 test("worker and run actions use POST endpoints", async () => {
57 const fetch = fakeFetch((url) =>
58 jsonResponse(
59 url.pathname.endsWith("/stop")
60 ? {
61 action: "stop",
62 run_id: "run-1",
63 stopped: 1,
64 status: { runs: 1, workers: {} },
65 }
66 : {
67 action: url.pathname.endsWith("/restart")
68 ? "restart"
69 : url.pathname.endsWith("/stop")
70 ? "stop"
71 : "interrupt",
72 worker: { worker_id: "w1", artifacts: [] },
73 },
74 ),
75 );
76 const client = new CodeWhaleRuntimeClient({ fetch });
77
78 await client.interruptWorker("w1");
79 await client.stopWorker("w1");
80 await client.restartWorker("w1");
81 await client.startFleetRun("run-1");
82 await client.stopFleetRun("run-1");
83
84 assert.deepEqual(
85 fetch.calls.map((call) => [new URL(call.url).pathname, call.init.method]),
86 [
87 ["/v1/fleet/workers/w1/interrupt", "POST"],
88 ["/v1/fleet/workers/w1/stop", "POST"],
89 ["/v1/fleet/workers/w1/restart", "POST"],
90 ["/v1/fleet/runs/run-1/start", "POST"],
91 ["/v1/fleet/runs/run-1/stop", "POST"],
92 ],
93 );
94 });
95
96 test("managed Fleet helpers send explicit launch metadata and reconnect cursors", async () => {
97 const fetch = fakeFetch((url) =>
98 jsonResponse(
99 url.pathname.endsWith("/events/replay")
100 ? { run_id: "run-1", events: [], has_more: false, history_truncated: false }
101 : { execution: "awaiting_start", run: { id: "run-1" }, warnings: [] },
102 ),
103 );
104 const client = new CodeWhaleRuntimeClient({ fetch });
105 const spec = {
106 target: "this_computer",
107 roles: [{ name: "reviewer" }],
108 workflow: {
109 id: "review",
110 kind: "parallel",
111 tasks: [
112 {
113 id: "review",
114 name: "Review",
115 instructions: "Review.",
116 worker: { role: "reviewer" },
117 budget: { max_steps: 0 },
118 },
119 ],
120 },
121 };
122
123 await client.createFleetRun(spec);
124 await client.replayFleetEvents("run-1", { after: "fev1_cursor_worker", limit: 25 });
125
126 assert.deepEqual(JSON.parse(fetch.calls[0].init.body), spec);
127 assert.equal(JSON.parse(fetch.calls[0].init.body).workflow.tasks[0].budget.max_steps, 0);
128 const replayUrl = new URL(fetch.calls[1].url);
129 assert.equal(replayUrl.pathname, "/v1/fleet/runs/run-1/events/replay");
130 assert.equal(replayUrl.searchParams.get("after"), "fev1_cursor_worker");
131 assert.equal(replayUrl.searchParams.get("limit"), "25");
132 });
133
134 test("unsupported fleet capabilities raise typed errors", async () => {
135 const fetch = fakeFetch(() => jsonResponse({ error: "not found" }, { status: 404 }));
136 const client = new CodeWhaleRuntimeClient({ fetch });
137
138 await assert.rejects(
139 () => client.createFleetRun({ name: "future" }),
140 (error) =>
141 error instanceof RuntimeCapabilityError &&
142 error.capability === "fleet_run_create" &&
143 error.status === 404,
144 );
145
146 await assert.rejects(
147 async () => {
148 for await (const _event of client.fleetEvents("run-1")) {
149 throw new Error("unexpected event");
150 }
151 },
152 (error) =>
153 error instanceof RuntimeCapabilityError &&
154 error.capability === "fleet_event_stream" &&
155 error.status === 404,
156 );
157 });
158
159 test("fleetEvents can replay JSON event fixtures when the API exposes them", async () => {
160 const fetch = fakeFetch(() =>
161 jsonResponse({
162 events: [
163 {
164 seq: 1,
165 run_id: "run-1",
166 worker_id: "w1",
167 task_id: "task-1",
168 timestamp: "2026-06-13T00:00:00Z",
169 label: "running",
170 payload: { state: "running" },
171 },
172 ],
173 }),
174 );
175 const client = new CodeWhaleRuntimeClient({ fetch });
176
177 const events = [];
178 for await (const event of client.fleetEvents("run-1", { path: "/v1/fleet/runs/run-1/events" })) {
179 events.push(event);
180 }
181
182 assert.equal(events.length, 1);
183 assert.equal(events[0].payload.state, "running");
184 });
185
186 test("fleetEvents parses text/event-stream frames", async () => {
187 const encoder = new TextEncoder();
188 const body = new ReadableStream({
189 start(controller) {
190 controller.enqueue(
191 encoder.encode(
192 'id: fev1_heartbeat_worker\nevent: fleet.worker.heartbeat\ndata: {"cursor":"fev1_heartbeat_worker","event":"fleet.worker.heartbeat","run_id":"run-1","worker_id":"w1","task_id":"task-1","timestamp":"2026-06-13T00:00:01Z","worker_seq":2,"payload":{"state":"heartbeat","memory_mb":128}}\n\n',
193 ),
194 );
195 controller.close();
196 },
197 });
198 const fetch = fakeFetch(
199 () =>
200 new Response(body, {
201 status: 200,
202 headers: { "content-type": "text/event-stream" },
203 }),
204 );
205 const client = new CodeWhaleRuntimeClient({ fetch });
206
207 const events = [];
208 for await (const event of client.fleetEvents("run-1", { after: "fev1_previous", limit: 10 })) {
209 events.push(event);
210 }
211
212 assert.equal(events.length, 1);
213 assert.equal(events[0].payload.state, "heartbeat");
214 assert.equal(events[0].payload.memory_mb, 128);
215 const eventUrl = new URL(fetch.calls[0].url);
216 assert.equal(eventUrl.searchParams.get("after"), "fev1_previous");
217 assert.equal(eventUrl.searchParams.get("limit"), "10");
218 assert.equal(fetch.calls[0].init.headers.get("accept"), "text/event-stream");
219 });
220
221 test("fleetEvents preserves SSE control event names", async () => {
222 const encoder = new TextEncoder();
223 const body = new ReadableStream({
224 start(controller) {
225 controller.enqueue(
226 encoder.encode(
227 'event: fleet.replay.cursor_unavailable\r\ndata: {"run_id":"run-1","reload_projection":true}\r\n\r\n',
228 ),
229 );
230 controller.close();
231 },
232 });
233 const client = new CodeWhaleRuntimeClient({
234 fetch: fakeFetch(
235 () =>
236 new Response(body, {
237 status: 200,
238 headers: { "content-type": "text/event-stream" },
239 }),
240 ),
241 });
242
243 const events = [];
244 for await (const event of client.fleetEvents("run-1")) {
245 events.push(event);
246 }
247
248 assert.deepEqual(events, [
249 {
250 event: "fleet.replay.cursor_unavailable",
251 run_id: "run-1",
252 reload_projection: true,
253 },
254 ]);
255 });
256
257 test("ordinary HTTP errors remain RuntimeApiError", async () => {
258 const fetch = fakeFetch(() => jsonResponse({ error: "bad" }, { status: 500 }));
259 const client = new CodeWhaleRuntimeClient({ fetch });
260
261 await assert.rejects(
262 () => client.getFleetRun("run-1"),
263 (error) =>
264 error instanceof RuntimeApiError &&
265 !(error instanceof RuntimeCapabilityError) &&
266 error.status === 500,
267 );
268 });
269
269 lines Plain Text