forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathops-api.test.ts
More file actions
466 lines (429 loc) · 15.5 KB
/
Copy pathops-api.test.ts
File metadata and controls
466 lines (429 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
/**
* Tests for the showcase-harness fetch client.
*
* Mocks `globalThis.fetch` and asserts the on-the-wire contract that B3
* (the showcase-harness HTTP API) is being built to satisfy:
* - GET <base>/probes → ProbesResponse
* - GET <base>/probes/<id> → { probe, runs }
* - POST <base>/probes/<id>/trigger → TriggerResponse
*
* `baseUrl` resolution order is: explicit param → runtimeConfig.opsBaseUrl
* (from `window.__SHOWCASE_CONFIG__`) → fallback `/api/ops` (proxy). The
* trigger token is supplied per-call; the client just attaches it as a
* Bearer header.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchProbes,
fetchProbeDetail,
triggerProbe,
type ProbesResponse,
type TriggerResponse,
type ProbeScheduleEntry,
type ProbeRun,
} from "./ops-api";
type FetchInit = Parameters<typeof fetch>[1];
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function emptyProbesResponse(): ProbesResponse {
return { probes: [] };
}
function sampleEntry(): ProbeScheduleEntry {
return {
id: "smoke",
kind: "smoke",
schedule: "*/5 * * * *",
nextRunAt: "2026-04-25T12:00:00Z",
lastRun: {
startedAt: "2026-04-25T11:55:00Z",
finishedAt: "2026-04-25T11:55:30Z",
durationMs: 30_000,
state: "completed",
summary: { total: 17, passed: 17, failed: 0 },
},
inflight: null,
config: { timeout_ms: 60_000, max_concurrency: 5, discovery: null },
};
}
function sampleRun(): ProbeRun {
return {
id: "run-1",
probeId: "smoke",
startedAt: "2026-04-25T11:55:00Z",
finishedAt: "2026-04-25T11:55:30Z",
durationMs: 30_000,
triggered: false,
summary: { total: 17, passed: 17, failed: 0 },
};
}
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
// Reset runtime config across tests so explicit-param vs env-var
// resolution is exercised cleanly. The runtime config is normally
// injected by the root layout into `window.__SHOWCASE_CONFIG__`.
delete (window as Window & { __SHOWCASE_CONFIG__?: unknown })
.__SHOWCASE_CONFIG__;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("fetchProbes", () => {
it("hits <base>/probes and parses the response", async () => {
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
const out = await fetchProbes({ baseUrl: "http://ops.test" });
expect(out).toEqual({ probes: [] });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("http://ops.test/probes");
});
it("falls back to /api/ops when no baseUrl is supplied", async () => {
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes();
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/ops/probes");
});
it("uses runtimeConfig.opsBaseUrl when explicit param omitted", async () => {
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
{
pocketbaseUrl: "",
shellUrl: "",
opsBaseUrl: "https://ops.example.com",
};
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes();
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("https://ops.example.com/probes");
});
it("uses same-origin /api/ops when opsBaseUrl is empty (no client-direct override)", async () => {
// Regression for the staging no-data bug: when the injected client
// config carries an EMPTY opsBaseUrl (the production default — the
// server proxy target OPS_BASE_URL is NOT leaked into the client),
// the client must fall through to the same-origin /api/ops proxy
// rather than fetching the harness cross-origin.
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
{
pocketbaseUrl: "",
shellUrl: "",
opsBaseUrl: "",
};
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes();
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/ops/probes");
// And explicitly NOT the cross-origin harness path.
expect(String(url)).not.toMatch(/^https?:\/\//);
});
it("does NOT fetch the harness directly when only the server proxy target is configured", async () => {
// The injected client config never carries the harness URL as a
// fetch base in production. Simulate the post-fix injection: the
// server proxy target lives only in process.env.OPS_BASE_URL (read
// by the Route Handler), and the client config's opsBaseUrl stays
// empty. The client must hit /api/ops, never the harness origin.
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
{
pocketbaseUrl: "",
shellUrl: "",
opsBaseUrl: "",
};
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes();
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/ops/probes");
expect(String(url)).not.toContain("harness-staging");
});
it("strips trailing slashes from baseUrl", async () => {
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes({ baseUrl: "http://ops.test/" });
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("http://ops.test/probes");
});
it("propagates the AbortSignal to fetch", async () => {
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
const ctrl = new AbortController();
await fetchProbes({ signal: ctrl.signal, baseUrl: "http://ops.test" });
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
expect(init?.signal).toBe(ctrl.signal);
});
it("throws on non-2xx responses with the status in the message", async () => {
fetchSpy.mockResolvedValue(
new Response("nope", { status: 503, statusText: "Service Unavailable" }),
);
await expect(fetchProbes({ baseUrl: "http://ops.test" })).rejects.toThrow(
/503/,
);
});
it("throws on 4xx responses", async () => {
fetchSpy.mockResolvedValue(
new Response("bad", { status: 400, statusText: "Bad Request" }),
);
await expect(fetchProbes({ baseUrl: "http://ops.test" })).rejects.toThrow(
/400/,
);
});
});
describe("fetchProbeDetail", () => {
it("hits <base>/probes/<id> and returns probe + runs", async () => {
fetchSpy.mockResolvedValue(
jsonResponse({ probe: sampleEntry(), runs: [sampleRun()] }),
);
const out = await fetchProbeDetail("smoke", { baseUrl: "http://ops.test" });
expect(out.probe.id).toBe("smoke");
expect(out.runs).toHaveLength(1);
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("http://ops.test/probes/smoke");
});
it("URL-encodes the id segment", async () => {
fetchSpy.mockResolvedValue(
jsonResponse({ probe: sampleEntry(), runs: [] }),
);
await fetchProbeDetail("e2e demos", { baseUrl: "http://ops.test" });
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("http://ops.test/probes/e2e%20demos");
});
it("propagates AbortSignal", async () => {
fetchSpy.mockResolvedValue(
jsonResponse({ probe: sampleEntry(), runs: [] }),
);
const ctrl = new AbortController();
await fetchProbeDetail("smoke", {
signal: ctrl.signal,
baseUrl: "http://ops.test",
});
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
expect(init?.signal).toBe(ctrl.signal);
});
it("throws on 404", async () => {
fetchSpy.mockResolvedValue(
new Response("missing", { status: 404, statusText: "Not Found" }),
);
await expect(
fetchProbeDetail("nope", { baseUrl: "http://ops.test" }),
).rejects.toThrow(/404/);
});
});
describe("triggerProbe", () => {
function triggerOk(): TriggerResponse {
return {
runId: "run-42",
status: "queued",
probe: "smoke",
scope: ["agno", "langgraph"],
};
}
it("POSTs to <base>/probes/<id>/trigger with bearer + JSON body", async () => {
fetchSpy.mockResolvedValue(jsonResponse(triggerOk()));
const out = await triggerProbe("smoke", {
slugs: ["agno", "langgraph"],
token: "secret-token",
baseUrl: "http://ops.test",
});
expect(out.runId).toBe("run-42");
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]! as [string, FetchInit];
expect(String(url)).toBe("http://ops.test/probes/smoke/trigger");
expect(init?.method).toBe("POST");
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer secret-token");
expect(headers.get("content-type")).toMatch(/application\/json/);
const body = JSON.parse(String(init?.body));
expect(body).toEqual({ slugs: ["agno", "langgraph"] });
});
it("sends an empty-object body when no slugs supplied", async () => {
fetchSpy.mockResolvedValue(jsonResponse(triggerOk()));
await triggerProbe("smoke", {
token: "t",
baseUrl: "http://ops.test",
});
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
const body = JSON.parse(String(init?.body));
expect(body).toEqual({});
});
it("URL-encodes the id in the trigger URL", async () => {
fetchSpy.mockResolvedValue(jsonResponse(triggerOk()));
await triggerProbe("e2e demos", {
token: "t",
baseUrl: "http://ops.test",
});
const [url] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("http://ops.test/probes/e2e%20demos/trigger");
});
it("throws on 401 with status in message", async () => {
fetchSpy.mockResolvedValue(
new Response("nope", { status: 401, statusText: "Unauthorized" }),
);
await expect(
triggerProbe("smoke", {
token: "bad",
baseUrl: "http://ops.test",
}),
).rejects.toThrow(/401/);
});
it("throws on 5xx", async () => {
fetchSpy.mockResolvedValue(
new Response("boom", { status: 500, statusText: "Server Error" }),
);
await expect(
triggerProbe("smoke", {
token: "t",
baseUrl: "http://ops.test",
}),
).rejects.toThrow(/500/);
});
it("propagates AbortSignal to fetch (CR-B1.5)", async () => {
fetchSpy.mockResolvedValue(jsonResponse(triggerOk()));
const ctrl = new AbortController();
await triggerProbe("smoke", {
token: "t",
baseUrl: "http://ops.test",
signal: ctrl.signal,
});
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
expect(init?.signal).toBe(ctrl.signal);
});
});
describe("GET requests bypass cache (R3-D.1)", () => {
it("fetchProbes sends cache: 'no-store' to defeat browser/Next caching", async () => {
fetchSpy.mockResolvedValue(jsonResponse(emptyProbesResponse()));
await fetchProbes({ baseUrl: "http://ops.test" });
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
expect(init?.cache).toBe("no-store");
});
it("fetchProbeDetail sends cache: 'no-store' to defeat browser/Next caching", async () => {
fetchSpy.mockResolvedValue(
jsonResponse({ probe: sampleEntry(), runs: [] }),
);
await fetchProbeDetail("smoke", { baseUrl: "http://ops.test" });
const init = fetchSpy.mock.calls[0]![1] as FetchInit;
expect(init?.cache).toBe("no-store");
});
});
describe("ensureOk error handling (CR-B1.6)", () => {
it("includes a body-read failure marker when text() throws", async () => {
// Build a Response-like object whose `text()` throws and whose `ok` is
// false. Response.text() does not normally throw on Response objects
// with string bodies, so we hand-roll a stub.
const fakeResponse = {
ok: false,
status: 502,
statusText: "Bad Gateway",
text: vi.fn(async () => {
throw new Error("stream consumed");
}),
} as unknown as Response;
fetchSpy.mockResolvedValue(fakeResponse);
let caught: unknown = null;
try {
await fetchProbes({ baseUrl: "http://ops.test" });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/body read failed/);
// status should still appear so callers can match on it.
expect((caught as Error).message).toMatch(/502/);
});
it("re-throws AbortError as-is from response.text() (preserves name)", async () => {
// R2-C.3: when body read fails with AbortError (e.g. caller aborted
// mid-body), preserve the AbortError name so hooks can filter it.
const abortErr = new DOMException("aborted", "AbortError");
const fakeResponse = {
ok: false,
status: 503,
statusText: "Service Unavailable",
text: vi.fn(async () => {
throw abortErr;
}),
} as unknown as Response;
fetchSpy.mockResolvedValue(fakeResponse);
let caught: unknown = null;
try {
await fetchProbes({ baseUrl: "http://ops.test" });
} catch (err) {
caught = err;
}
// Must be the original AbortError, not a wrapped Error.
expect((caught as { name?: string })?.name).toBe("AbortError");
});
it("re-throws AbortError as-is from response.json() (parseJson)", async () => {
// R2-C.3: parseJson must propagate AbortError from response.json().
const abortErr = new DOMException("aborted", "AbortError");
const fakeResponse = {
ok: true,
status: 200,
statusText: "OK",
json: vi.fn(async () => {
throw abortErr;
}),
} as unknown as Response;
fetchSpy.mockResolvedValue(fakeResponse);
let caught: unknown = null;
try {
await fetchProbes({ baseUrl: "http://ops.test" });
} catch (err) {
caught = err;
}
expect((caught as { name?: string })?.name).toBe("AbortError");
});
it("still wraps non-Abort body-read failures with a descriptive message", async () => {
// R2-C.3: regression guard — non-Abort body errors keep the wrap path.
const fakeResponse = {
ok: false,
status: 502,
statusText: "Bad Gateway",
text: vi.fn(async () => {
throw new Error("stream consumed");
}),
} as unknown as Response;
fetchSpy.mockResolvedValue(fakeResponse);
let caught: unknown = null;
try {
await fetchProbes({ baseUrl: "http://ops.test" });
} catch (err) {
caught = err;
}
expect((caught as Error).message).toMatch(/body read failed/);
expect((caught as { name?: string })?.name).not.toBe("AbortError");
});
});
describe("triggerProbe abort behavior (R2-C.4)", () => {
function triggerOk(): TriggerResponse {
return {
runId: "run-9",
status: "queued",
probe: "smoke",
scope: [],
};
}
it("rejects with AbortError when called with an already-aborted signal", async () => {
// Real fetch raises AbortError synchronously when the signal is already
// aborted. We mimic that here so triggerProbe surfaces AbortError
// unwrapped (per R2-C.3).
fetchSpy.mockImplementation((_url: string, init?: FetchInit) => {
if (init?.signal?.aborted) {
return Promise.reject(new DOMException("aborted", "AbortError"));
}
return Promise.resolve(jsonResponse(triggerOk()));
});
const ctrl = new AbortController();
ctrl.abort();
let caught: unknown = null;
try {
await triggerProbe("smoke", {
token: "t",
baseUrl: "http://ops.test",
signal: ctrl.signal,
});
} catch (err) {
caught = err;
}
expect((caught as { name?: string })?.name).toBe("AbortError");
});
});