ccqa 1.51.0 → 1.52.0

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.
@@ -0,0 +1,59 @@
1
+ //#region src/runtime/evidence-constants.ts
2
+ /**
3
+ * Shared constants + helpers for step-boundary evidence: the env var that
4
+ * enables capture, the file-name sanitizer, and the reserved failure ids.
5
+ *
6
+ * Three producers write the `<id>.png` + `<id>.json` pairs — `abStepEvidence()`
7
+ * / `captureFailureEvidence()` in `test-helpers.ts` (agent-browser replays) and
8
+ * `ccqaStepBefore`/`ccqaStepAfter` in `step-evidence.ts` (external targets) —
9
+ * and one consumer reads them back (`loadEvidenceForSpec` in
10
+ * `report/evidence.ts`). All four agree only on the contract here, so it is
11
+ * kept under `runtime/` (free of CLI-side imports) so the generated-test
12
+ * modules — imported via `ccqa/test-helpers` and `ccqa/step-evidence` — can
13
+ * share it without dragging the CLI in.
14
+ */
15
+ /**
16
+ * Env var naming the directory a spec's step evidence is written to. `ccqa
17
+ * run` sets it per spec; unset means "capture nothing", which is what keeps a
18
+ * hand-run generated test (or the generation-time verify loop) from
19
+ * scattering screenshots.
20
+ */
21
+ const EVIDENCE_DIR_ENV = "CCQA_EVIDENCE_DIR";
22
+ /**
23
+ * Make a step id safe to use as an evidence file-name stem. Both evidence
24
+ * producers (agent-browser `abStepEvidence`, external `ccqa/step-evidence`)
25
+ * name their PNG/JSON pair `<stem>.png` / `<stem>.json`, so they must agree on
26
+ * this exact mapping.
27
+ */
28
+ function sanitizeStepId(stepId) {
29
+ return stepId.replace(/[^A-Za-z0-9_.-]/g, "_");
30
+ }
31
+ /** stepId reserved for the screenshot captured by fail() at the moment of an assertion failure. */
32
+ const FAILURE_STEP_ID = "failure";
33
+ /** source value paired with FAILURE_STEP_ID so the report can tell failure captures apart from step captures. */
34
+ const FAILURE_SOURCE = "failed";
35
+ //#endregion
36
+ Object.defineProperty(exports, "EVIDENCE_DIR_ENV", {
37
+ enumerable: true,
38
+ get: function() {
39
+ return EVIDENCE_DIR_ENV;
40
+ }
41
+ });
42
+ Object.defineProperty(exports, "FAILURE_SOURCE", {
43
+ enumerable: true,
44
+ get: function() {
45
+ return FAILURE_SOURCE;
46
+ }
47
+ });
48
+ Object.defineProperty(exports, "FAILURE_STEP_ID", {
49
+ enumerable: true,
50
+ get: function() {
51
+ return FAILURE_STEP_ID;
52
+ }
53
+ });
54
+ Object.defineProperty(exports, "sanitizeStepId", {
55
+ enumerable: true,
56
+ get: function() {
57
+ return sanitizeStepId;
58
+ }
59
+ });
@@ -0,0 +1,434 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/hub-client/index.ts
3
+ var HubApiError = class extends Error {
4
+ status;
5
+ code;
6
+ constructor(status, code, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ };
12
+ /** Per-attempt fetch timeout. Bounds how long a stalled socket can block a poll loop. */
13
+ const REQUEST_TIMEOUT_MS = 3e4;
14
+ /**
15
+ * HTTP methods safe to retry: GET is a pure read, and DELETE is idempotent
16
+ * (deleting an already-deleted resource is a no-op, not a new side effect).
17
+ * POST/PUT are never retried — a POST that "failed" after the server
18
+ * already committed it (e.g. a dropped response to pushRun) would create a
19
+ * duplicate run on retry, and PUT-driven imports would double-apply.
20
+ */
21
+ const RETRYABLE_METHODS = new Set(["GET", "DELETE"]);
22
+ /** Fixed backoff between retry attempts, in ms. */
23
+ const RETRY_BACKOFF_MS = [
24
+ 100,
25
+ 300,
26
+ 900
27
+ ];
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+ async function throwHubApiError(res) {
32
+ let code = "unknown_error";
33
+ let message = res.statusText;
34
+ try {
35
+ const body = await res.json();
36
+ if (body.error?.code) code = body.error.code;
37
+ if (body.error?.message) message = body.error.message;
38
+ } catch {}
39
+ throw new HubApiError(res.status, code, message);
40
+ }
41
+ /**
42
+ * One round trip against a hub, with the client's shared policy: bearer auth,
43
+ * per-attempt timeout, retries for idempotent methods, `HubApiError` on the
44
+ * final non-ok answer. Exported for the one caller outside the client
45
+ * (`CoverageInbox`), whose POSTs are appends a duplicate cannot corrupt —
46
+ * unlike the client's own POSTs — so it may opt into `retry: "post-once"`,
47
+ * one extra attempt after a 5xx or network error.
48
+ */
49
+ async function hubRequest(opts, path, init = {}, retry) {
50
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
51
+ const doFetch = opts.fetchImpl ?? fetch;
52
+ const method = (init.method ?? "GET").toUpperCase();
53
+ const maxAttempts = retry === "post-once" ? 2 : RETRYABLE_METHODS.has(method) ? RETRY_BACKOFF_MS.length + 1 : 1;
54
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
55
+ const signal = init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS);
56
+ let res;
57
+ try {
58
+ res = await doFetch(`${baseUrl}${path}`, {
59
+ ...init,
60
+ signal,
61
+ headers: {
62
+ ...opts.headers,
63
+ ...init.headers,
64
+ Authorization: `Bearer ${opts.token}`
65
+ }
66
+ });
67
+ } catch (err) {
68
+ if (attempt < maxAttempts - 1) {
69
+ await sleep(RETRY_BACKOFF_MS[attempt]);
70
+ continue;
71
+ }
72
+ throw err;
73
+ }
74
+ if (res.ok) return res;
75
+ if (res.status >= 500 && attempt < maxAttempts - 1) {
76
+ await sleep(RETRY_BACKOFF_MS[attempt]);
77
+ continue;
78
+ }
79
+ return throwHubApiError(res);
80
+ }
81
+ throw new Error("unreachable");
82
+ }
83
+ function createHubClient(opts) {
84
+ function request(path, init = {}) {
85
+ return hubRequest(opts, path, init);
86
+ }
87
+ async function json(path, init) {
88
+ return (await request(path, init)).json();
89
+ }
90
+ async function bytes(path, init) {
91
+ const buf = await (await request(path, init)).arrayBuffer();
92
+ return new Uint8Array(buf);
93
+ }
94
+ async function text(path, init) {
95
+ return (await request(path, init)).text();
96
+ }
97
+ function noBody(path, method) {
98
+ return request(path, { method }).then(() => void 0);
99
+ }
100
+ function putJson(path, body) {
101
+ return request(path, {
102
+ method: "PUT",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify(body)
105
+ }).then(() => void 0);
106
+ }
107
+ return {
108
+ pushRun(archive, meta) {
109
+ const params = new URLSearchParams({ project: meta.project });
110
+ if (meta.branch) params.set("branch", meta.branch);
111
+ if (meta.profile) params.set("profile", meta.profile);
112
+ if (meta.kind) params.set("kind", meta.kind);
113
+ if (meta.deployedSha) params.set("deployedSha", meta.deployedSha);
114
+ return json(`/api/v1/runs?${params}`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/gzip" },
117
+ body: toBodyInit(archive)
118
+ });
119
+ },
120
+ openRun(meta) {
121
+ const params = new URLSearchParams({ project: meta.project });
122
+ if (meta.branch) params.set("branch", meta.branch);
123
+ if (meta.profile) params.set("profile", meta.profile);
124
+ if (meta.kind) params.set("kind", meta.kind);
125
+ if (meta.gitHead) params.set("gitHead", meta.gitHead);
126
+ if (meta.deployedSha) params.set("deployedSha", meta.deployedSha);
127
+ if (meta.ciRunId) params.set("ciRunId", meta.ciRunId);
128
+ if (meta.runUrl) params.set("runUrl", meta.runUrl);
129
+ return json(`/api/v1/runs/open?${params}`, { method: "POST" });
130
+ },
131
+ patchRun(id, body) {
132
+ return json(`/api/v1/runs/${encodeURIComponent(id)}`, {
133
+ method: "PATCH",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify(body)
136
+ });
137
+ },
138
+ async listRuns(q = {}) {
139
+ const { runs } = await json(`/api/v1/runs?${queryString(q)}`);
140
+ return runs;
141
+ },
142
+ getRun(id) {
143
+ return json(`/api/v1/runs/${encodeURIComponent(id)}`);
144
+ },
145
+ getReport(id) {
146
+ return json(`/api/v1/runs/${encodeURIComponent(id)}/report`);
147
+ },
148
+ downloadArtifacts(id) {
149
+ return bytes(`/api/v1/runs/${encodeURIComponent(id)}/artifacts`);
150
+ },
151
+ getCoverage(project, q = {}) {
152
+ return json(`/api/v1/coverage?${queryString({
153
+ project,
154
+ runId: q.runId
155
+ })}`);
156
+ },
157
+ putCoverageEdges(project, upsert) {
158
+ return request(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`, {
159
+ method: "PUT",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify(upsert)
162
+ }).then(() => void 0);
163
+ },
164
+ async getCoverageEdges(project) {
165
+ try {
166
+ return await json(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`);
167
+ } catch (err) {
168
+ if (err instanceof HubApiError && err.status === 404) return null;
169
+ throw err;
170
+ }
171
+ },
172
+ putSourceMap(project, commit, assetPath, map) {
173
+ return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
174
+ method: "PUT",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: toBodyInit(map)
177
+ }).then(() => void 0);
178
+ },
179
+ async getSourceMap(project, commit, assetPath) {
180
+ try {
181
+ return await text(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`);
182
+ } catch (err) {
183
+ if (err instanceof HubApiError && err.status === 404) return null;
184
+ throw err;
185
+ }
186
+ },
187
+ sweepSourceMaps(project) {
188
+ return noBody(`/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/sweep`, "POST");
189
+ },
190
+ async listSourceMaps(project, commit) {
191
+ const { paths } = await json(sourceMapPath(project, commit));
192
+ return paths;
193
+ },
194
+ getTriage(id) {
195
+ return json(`/api/v1/runs/${encodeURIComponent(id)}/triage`);
196
+ },
197
+ putActualCause(id, c, v) {
198
+ return json(`/api/v1/runs/${encodeURIComponent(id)}/triage/${encodeURIComponent(c.feature)}/${encodeURIComponent(c.spec)}/actual-cause`, {
199
+ method: "PUT",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify(v)
202
+ });
203
+ },
204
+ deleteActualCause(id, c) {
205
+ return noBody(`/api/v1/runs/${encodeURIComponent(id)}/triage/${encodeURIComponent(c.feature)}/${encodeURIComponent(c.spec)}/actual-cause`, "DELETE");
206
+ },
207
+ importActualCauses(id, labels) {
208
+ return json(`/api/v1/runs/${encodeURIComponent(id)}/triage/actual-causes`, {
209
+ method: "PUT",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: JSON.stringify(labels)
212
+ });
213
+ },
214
+ async getLastGreen(project, q) {
215
+ const params = queryString({
216
+ branch: q.branch,
217
+ ...q.profile ? { profile: q.profile } : {},
218
+ ...q.fallbackBranch ? { fallbackBranch: q.fallbackBranch } : {}
219
+ });
220
+ const { entries } = await json(`/api/v1/projects/${encodeURIComponent(project)}/last-green?${params}`);
221
+ return entries;
222
+ },
223
+ getRerun(project, q) {
224
+ return json(`/api/v1/projects/${encodeURIComponent(project)}/rerun?${queryString({ profile: q.profile })}`);
225
+ },
226
+ acquireLocks(project, q, body) {
227
+ return json(`${locksPath(project)}?${queryString({ profile: q.profile })}`, {
228
+ method: "POST",
229
+ headers: { "Content-Type": "application/json" },
230
+ body: JSON.stringify(body)
231
+ });
232
+ },
233
+ async releaseLocks(project, q, holder) {
234
+ await request(`${locksPath(project)}?${queryString({ profile: q.profile })}`, {
235
+ method: "DELETE",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ holder })
238
+ });
239
+ },
240
+ getAttestations(project, q) {
241
+ return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`);
242
+ },
243
+ putAttestation(project, q, body) {
244
+ return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
245
+ method: "PUT",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify(body)
248
+ });
249
+ },
250
+ async deleteAttestation(project, q, spec) {
251
+ await request(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
252
+ method: "DELETE",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify({ spec })
255
+ });
256
+ },
257
+ getAuditDismissals(project) {
258
+ return json(auditDismissalsPath(project));
259
+ },
260
+ putAuditDismissal(project, body) {
261
+ return json(auditDismissalsPath(project), {
262
+ method: "PUT",
263
+ headers: { "Content-Type": "application/json" },
264
+ body: JSON.stringify(body)
265
+ });
266
+ },
267
+ async deleteAuditDismissal(project, spec) {
268
+ await request(auditDismissalsPath(project), {
269
+ method: "DELETE",
270
+ headers: { "Content-Type": "application/json" },
271
+ body: JSON.stringify({ spec })
272
+ });
273
+ },
274
+ getAuditNeed(project, q) {
275
+ return json(`/api/v1/projects/${encodeURIComponent(project)}/audit-needed?${queryString({ profile: q.profile })}`);
276
+ },
277
+ getDriftLedger(project) {
278
+ return json(`/api/v1/projects/${encodeURIComponent(project)}/drift`);
279
+ },
280
+ recordDeploy(project, profile, body) {
281
+ return json(`${deploysPath(project)}?${queryString({ profile })}`, {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json" },
284
+ body: JSON.stringify(body)
285
+ });
286
+ },
287
+ getDeployLog(project, q) {
288
+ return json(`${deploysPath(project)}?${queryString({
289
+ profile: q.profile,
290
+ limit: q.limit
291
+ })}`);
292
+ },
293
+ recordSpend(project, body) {
294
+ return json(spendPath(project), {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json" },
297
+ body: JSON.stringify(body)
298
+ });
299
+ },
300
+ getSpend(project, q = {}) {
301
+ return json(`${spendPath(project)}?${queryString({
302
+ since: q.since,
303
+ until: q.until
304
+ })}`);
305
+ },
306
+ async listProjects() {
307
+ const { projects } = await json("/api/v1/projects");
308
+ return projects;
309
+ },
310
+ putSession(project, profile, name, storageState) {
311
+ return putJson(`${scopePath(project, "sessions", profile)}/${encodeURIComponent(name)}`, storageState);
312
+ },
313
+ getSession(project, profile, name) {
314
+ return json(`${scopePath(project, "sessions", profile)}/${encodeURIComponent(name)}`);
315
+ },
316
+ async listSessions(project, profile) {
317
+ const { sessions } = await json(scopePath(project, "sessions", profile));
318
+ return sessions;
319
+ },
320
+ deleteSession(project, profile, name) {
321
+ return noBody(`${scopePath(project, "sessions", profile)}/${encodeURIComponent(name)}`, "DELETE");
322
+ },
323
+ putVariable(project, profile, name, v) {
324
+ return putJson(`${scopePath(project, "variables", profile)}/${encodeURIComponent(name)}`, v);
325
+ },
326
+ async listVariables(project, profile, opts = {}) {
327
+ const query = opts.includeValues ? "?include=values" : "";
328
+ const { variables } = await json(`${scopePath(project, "variables", profile)}${query}`);
329
+ return variables;
330
+ },
331
+ deleteVariable(project, profile, name) {
332
+ return noBody(`${scopePath(project, "variables", profile)}/${encodeURIComponent(name)}`, "DELETE");
333
+ },
334
+ putPrompt(project, name, body) {
335
+ return request(`${promptsPath(project)}/${encodeURIComponent(name)}`, {
336
+ method: "PUT",
337
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
338
+ body
339
+ }).then(() => void 0);
340
+ },
341
+ async getPrompt(project, name) {
342
+ try {
343
+ return await text(`${promptsPath(project)}/${encodeURIComponent(name)}`);
344
+ } catch (err) {
345
+ if (err instanceof HubApiError && err.status === 404) return null;
346
+ throw err;
347
+ }
348
+ },
349
+ async listPrompts(project) {
350
+ const { prompts } = await json(promptsPath(project));
351
+ return prompts;
352
+ },
353
+ deletePrompt(project, name) {
354
+ return noBody(`${promptsPath(project)}/${encodeURIComponent(name)}`, "DELETE");
355
+ },
356
+ putPerspectives(project, doc) {
357
+ return putJson(perspectivesPath(project), doc);
358
+ },
359
+ patchPerspectivesNote(project, c) {
360
+ return request(perspectivesPath(project), {
361
+ method: "PATCH",
362
+ headers: { "Content-Type": "application/json" },
363
+ body: JSON.stringify(c)
364
+ }).then(() => void 0);
365
+ },
366
+ async getPerspectives(project) {
367
+ try {
368
+ return await json(perspectivesPath(project));
369
+ } catch (err) {
370
+ if (err instanceof HubApiError && err.status === 404) return null;
371
+ throw err;
372
+ }
373
+ },
374
+ deletePerspectives(project) {
375
+ return noBody(perspectivesPath(project), "DELETE");
376
+ }
377
+ };
378
+ }
379
+ /** `/api/v1/projects/<project>/<kind>/<profile>` — the scope prefix secret endpoints share. */
380
+ function scopePath(project, kind, profile) {
381
+ return `/api/v1/projects/${encodeURIComponent(project)}/${kind}/${encodeURIComponent(profile)}`;
382
+ }
383
+ /** Prompts are project-scoped (not per-profile): `/api/v1/projects/<project>/prompts`. */
384
+ function promptsPath(project) {
385
+ return `/api/v1/projects/${encodeURIComponent(project)}/prompts`;
386
+ }
387
+ /** The deploy log is per project, selected by a `?profile=` query param: `/api/v1/projects/<project>/deploys`. */
388
+ function deploysPath(project) {
389
+ return `/api/v1/projects/${encodeURIComponent(project)}/deploys`;
390
+ }
391
+ function spendPath(project) {
392
+ return `/api/v1/projects/${encodeURIComponent(project)}/spend`;
393
+ }
394
+ function locksPath(project) {
395
+ return `/api/v1/projects/${encodeURIComponent(project)}/locks`;
396
+ }
397
+ function attestationsPath(project) {
398
+ return `/api/v1/projects/${encodeURIComponent(project)}/attestations`;
399
+ }
400
+ function auditDismissalsPath(project) {
401
+ return `/api/v1/projects/${encodeURIComponent(project)}/audit-dismissals`;
402
+ }
403
+ /** Perspectives are one document per project: `/api/v1/projects/<project>/perspectives`. */
404
+ function perspectivesPath(project) {
405
+ return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
406
+ }
407
+ /** `/api/v1/projects/<project>/sourcemaps/<commit>` — the scope a push and a read share. */
408
+ function sourceMapPath(project, commit) {
409
+ return `/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/${encodeURIComponent(commit)}`;
410
+ }
411
+ /**
412
+ * Asset paths keep their separators — the route matches the rest of the URL as
413
+ * one wildcard segment — so only the parts between them are escaped.
414
+ */
415
+ function encodeAssetPath(assetPath) {
416
+ return assetPath.split("/").map(encodeURIComponent).join("/");
417
+ }
418
+ function queryString(params) {
419
+ const out = new URLSearchParams();
420
+ for (const [key, value] of Object.entries(params)) if (value !== void 0) out.set(key, String(value));
421
+ return out;
422
+ }
423
+ /**
424
+ * `Uint8Array` isn't a valid `BodyInit` in every fetch implementation's
425
+ * types (browser lib.dom vs Node's undici disagree) — go through a plain
426
+ * `ArrayBuffer` slice, which every implementation accepts.
427
+ */
428
+ function toBodyInit(bytes) {
429
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
430
+ }
431
+ //#endregion
432
+ exports.HubApiError = HubApiError;
433
+ exports.createHubClient = createHubClient;
434
+ exports.hubRequest = hubRequest;