ccqa 1.44.0 → 1.45.1

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/dist/bin/ccqa.mjs CHANGED
@@ -7167,6 +7167,7 @@ async function startBrowserCoverage(opts) {
7167
7167
  var Engine = class {
7168
7168
  client;
7169
7169
  connectFn;
7170
+ /** Last known address of the browser; see `refreshWsUrl`. */
7170
7171
  wsUrl;
7171
7172
  reconnects = 0;
7172
7173
  reconnectInFlight = false;
@@ -7285,6 +7286,22 @@ var Engine = class {
7285
7286
  this.stopped = true;
7286
7287
  this.clearTimer();
7287
7288
  }
7289
+ /**
7290
+ * Answers why the address could not be refreshed, so the retry that follows
7291
+ * says whether it is dialling a stale address or a dead browser. Told apart
7292
+ * because one is a driver to fix and the other is a browser to wait for, and
7293
+ * a silent stale address is the shape of the bug this exists to prevent.
7294
+ */
7295
+ async refreshWsUrl() {
7296
+ const ask = this.opts.currentCdpUrl;
7297
+ if (ask === void 0) return void 0;
7298
+ try {
7299
+ this.wsUrl = await browserWebSocketUrl(await ask());
7300
+ return;
7301
+ } catch (error) {
7302
+ return message(error);
7303
+ }
7304
+ }
7288
7305
  async reconnect(initialReason) {
7289
7306
  if (this.reconnectInFlight) return;
7290
7307
  this.reconnectInFlight = true;
@@ -7300,11 +7317,12 @@ var Engine = class {
7300
7317
  this.opts.warn(`browser coverage transport dropped (${reason}); reconnecting in ${delay}ms (${this.reconnects}/${MAX_RECONNECTS})`);
7301
7318
  await new Promise((resolve) => setTimeout(resolve, delay));
7302
7319
  if (this.stopped) return;
7320
+ const stale = await this.refreshWsUrl();
7303
7321
  let fresh;
7304
7322
  try {
7305
7323
  fresh = await this.connectFn(this.wsUrl);
7306
7324
  } catch (error) {
7307
- reason = `reconnect failed: ${message(error)}`;
7325
+ reason = stale ? `reconnect failed: ${message(error)} (address may be stale: ${stale})` : `reconnect failed: ${message(error)}`;
7308
7326
  continue;
7309
7327
  }
7310
7328
  if (this.stopped) {
@@ -7319,6 +7337,7 @@ var Engine = class {
7319
7337
  reason = `reconnect failed: ${message(error)}`;
7320
7338
  continue;
7321
7339
  }
7340
+ this.reconnects = 0;
7322
7341
  if (this.stopped) {
7323
7342
  this.clearTimer();
7324
7343
  fresh.close();
@@ -7749,9 +7768,10 @@ var CoverageSession = class CoverageSession {
7749
7768
  * destinations, the roots — lives here, so the caller only supplies where
7750
7769
  * the browser is.
7751
7770
  */
7752
- armBrowser(ref, cdpUrl, coverageDir) {
7771
+ armBrowser(ref, browser, coverageDir) {
7753
7772
  return startBrowserCoverage({
7754
- cdpUrl,
7773
+ cdpUrl: browser.cdpUrl,
7774
+ currentCdpUrl: browser.currentCdpUrl?.bind(browser),
7755
7775
  specId: specIdFor(this.runId, ref),
7756
7776
  origins: this.origins,
7757
7777
  assetOrigins: this.assetOrigins,
@@ -8257,7 +8277,7 @@ async function runOneSpec$1(ref, opts, blocks) {
8257
8277
  });
8258
8278
  const acquired = browserHandle;
8259
8279
  opts.teardown?.onFinalize(() => acquired.dispose());
8260
- browserEngine = await measurement.collector.armBrowser(ref, browserHandle.cdpUrl, coverageDir);
8280
+ browserEngine = await measurement.collector.armBrowser(ref, browserHandle, coverageDir);
8261
8281
  if (browserHandle.amendCommand) command = browserHandle.amendCommand(command);
8262
8282
  Object.assign(childEnv, browserHandle.env);
8263
8283
  } catch (err) {
@@ -12337,7 +12357,7 @@ var StreamResolution = class {
12337
12357
  asOf = 0;
12338
12358
  lastSeq = 0;
12339
12359
  pushesDuringRun = 0;
12340
- /** `markers` needs to hold every marker event of the stream; pushes are ignored here. */
12360
+ /** `markers` needs to hold every marker `runId` issued; other runs' markers and pushes are ignored. */
12341
12361
  constructor(markers, runId) {
12342
12362
  this.runId = runId;
12343
12363
  const specIds = /* @__PURE__ */ new Set();
@@ -12460,16 +12480,22 @@ var StreamResolution = class {
12460
12480
  /**
12461
12481
  * Every run that opened a spec in this stream, most recently heard-from
12462
12482
  * first — recency by the arrival position of each run's latest spec-open,
12463
- * the one order the hub's stamps establish.
12483
+ * the one order the hub's stamps establish. Fed one event at a time, so the
12484
+ * question can be asked of a stream too large to hold.
12464
12485
  */
12465
- function listRunIds(events) {
12466
- const lastOpenIndex = /* @__PURE__ */ new Map();
12467
- events.forEach((event, index) => {
12486
+ var RunIdIndex = class {
12487
+ /** Re-inserted on every `spec-open`, so insertion order is order of latest open. */
12488
+ opened = /* @__PURE__ */ new Set();
12489
+ accept(event) {
12468
12490
  const body = event.body;
12469
- if ("kind" in body && body.kind === "spec-open") lastOpenIndex.set(body.runId, index);
12470
- });
12471
- return [...lastOpenIndex.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
12472
- }
12491
+ if (!("kind" in body) || body.kind !== "spec-open") return;
12492
+ this.opened.delete(body.runId);
12493
+ this.opened.add(body.runId);
12494
+ }
12495
+ newestFirst() {
12496
+ return [...this.opened].reverse();
12497
+ }
12498
+ };
12473
12499
  //#endregion
12474
12500
  //#region src/cli/session.ts
12475
12501
  const AB = resolveAgentBrowserBin$1();
@@ -13878,6 +13904,37 @@ async function acquireAgentBrowserEndpoint(ctx) {
13878
13904
  "about:blank"
13879
13905
  ]);
13880
13906
  if (warm.status !== 0) throw new Error(`could not start the session's browser: ${warm.stderr || warm.stdout}`);
13907
+ return {
13908
+ cdpUrl: askCdpUrl(session),
13909
+ currentCdpUrl: () => askCdpUrlSoon(session),
13910
+ dispose: async () => {}
13911
+ };
13912
+ }
13913
+ /**
13914
+ * The same question on a short leash. Its caller is racing its own backoff
13915
+ * between reconnect attempts, so an answer that arrives after the budget is
13916
+ * spent is worth less than no answer: `spawnAB` would sit through a 30s EAGAIN
13917
+ * retry and a 35s hard timeout, which is the whole reconnect window several
13918
+ * times over.
13919
+ */
13920
+ const CDP_URL_TIMEOUT_MS = 2e3;
13921
+ async function askCdpUrlSoon(session) {
13922
+ const { promisify } = await import("node:util");
13923
+ const { execFile } = await import("node:child_process");
13924
+ const { stdout } = await promisify(execFile)(resolveAgentBrowserBin$1(), [
13925
+ "--session",
13926
+ session,
13927
+ "get",
13928
+ "cdp-url"
13929
+ ], {
13930
+ timeout: CDP_URL_TIMEOUT_MS,
13931
+ encoding: "utf8"
13932
+ });
13933
+ const cdpUrl = stdout.trim().split("\n").pop()?.trim() ?? "";
13934
+ if (!/^wss?:\/\//.test(cdpUrl)) throw new Error(`agent-browser answered no cdp-url for session ${session}`);
13935
+ return cdpUrl;
13936
+ }
13937
+ function askCdpUrl(session) {
13881
13938
  const answer = spawnAB([
13882
13939
  "--session",
13883
13940
  session,
@@ -13886,10 +13943,7 @@ async function acquireAgentBrowserEndpoint(ctx) {
13886
13943
  ]);
13887
13944
  const cdpUrl = answer.stdout.trim().split("\n").pop()?.trim() ?? "";
13888
13945
  if (answer.status !== 0 || !/^wss?:\/\//.test(cdpUrl)) throw new Error(`agent-browser did not answer \`get cdp-url\` for session ${session}: ${answer.stderr || answer.stdout}`);
13889
- return {
13890
- cdpUrl,
13891
- dispose: async () => {}
13892
- };
13946
+ return cdpUrl;
13893
13947
  }
13894
13948
  //#endregion
13895
13949
  //#region src/diagnose/snapshot.ts
@@ -14273,7 +14327,7 @@ async function runOneSpec(args) {
14273
14327
  browserEngine = await opts.coverage.armBrowser({
14274
14328
  featureName,
14275
14329
  specName
14276
- }, handle.cdpUrl, coverageDir);
14330
+ }, handle, coverageDir);
14277
14331
  } catch (err) {
14278
14332
  coverageBroken = errMessage(err);
14279
14333
  warn(`coverage: could not attach to the live browser (${coverageBroken})`);
@@ -17128,7 +17182,8 @@ async function executeRun(targets, opts) {
17128
17182
  const liveSpecs = withMode.filter((s) => s.mode === "live");
17129
17183
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
17130
17184
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
17131
- const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedSourceMapReader(hubCtx, deployedSha), hubCtx !== null) : void 0;
17185
+ const storedMaps = hubCtx !== null && deployedSha !== null ? createStoredSourceMaps(hubCtx, deployedSha) : void 0;
17186
+ const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedMaps?.read, hubCtx !== null) : void 0;
17132
17187
  if (liveSpecs.length === 0) {
17133
17188
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
17134
17189
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -17241,8 +17296,11 @@ async function executeRun(targets, opts) {
17241
17296
  };
17242
17297
  const live = await runLiveSpecs(liveSpecs, liveOpts);
17243
17298
  let streamedEdges = {};
17244
- if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
17245
- else if (coverage && hubCtx != null) streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
17299
+ if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults], storedMaps);
17300
+ else if (coverage && hubCtx != null) {
17301
+ streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
17302
+ storedMaps?.warnIfNothingAnswered();
17303
+ }
17246
17304
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
17247
17305
  if (live.failedCount > 0) overallExitCode = 1;
17248
17306
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -17336,6 +17394,12 @@ async function executeRun(targets, opts) {
17336
17394
  reportDir
17337
17395
  };
17338
17396
  }
17397
+ /**
17398
+ * Starts the run's coverage measurement before any spec runs: the sink has to
17399
+ * be listening before the first request reaches the application, and the set
17400
+ * of spec ids it will accept is only known once dispatch has resolved. With
17401
+ * an `inbox`, nothing binds — the run streams its events to the hub instead.
17402
+ */
17339
17403
  async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fetchStoredSourceMap, hubConfigured) {
17340
17404
  if (config === void 0) throw new RunUsageError("--coverage needs a `coverage:` block in .ccqa/config.yaml whose `instrumentedOrigins` names the origins the spec cookie may be attached to");
17341
17405
  let session;
@@ -17361,25 +17425,25 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fet
17361
17425
  return session;
17362
17426
  }
17363
17427
  /**
17364
- * Starts the run's coverage measurement before any spec runs: the sink has to
17365
- * be listening before the first request reaches the application, and the set
17366
- * of spec ids it will accept is only known once dispatch has resolved. With
17367
- * an `inbox`, nothing binds — the run streams its events to the hub instead.
17428
+ * The maps a deploy pushed for one commit. One answer per asset for the run:
17429
+ * the commit is fixed, so a miss stays a miss, and every spec would otherwise
17430
+ * re-ask for the same scripts.
17368
17431
  */
17369
- /**
17370
- * Needs both a hub and the commit under test: without either there is nothing
17371
- * to address a stored map by, and reading some other commit's maps would name
17372
- * the wrong files. See `SourceMapStore`.
17373
- */
17374
- function storedSourceMapReader(hubCtx, deployedSha) {
17375
- if (hubCtx === null || deployedSha === null) return void 0;
17432
+ function createStoredSourceMaps(hubCtx, deployedSha) {
17376
17433
  const seen = /* @__PURE__ */ new Map();
17377
- return async (assetPath) => {
17378
- const cached = seen.get(assetPath);
17379
- if (cached !== void 0 || seen.has(assetPath)) return cached;
17380
- const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
17381
- seen.set(assetPath, map);
17382
- return map;
17434
+ return {
17435
+ read: async (assetPath) => {
17436
+ const cached = seen.get(assetPath);
17437
+ if (cached !== void 0 || seen.has(assetPath)) return cached;
17438
+ const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
17439
+ seen.set(assetPath, map);
17440
+ return map;
17441
+ },
17442
+ warnIfNothingAnswered() {
17443
+ const answered = [...seen.values()].some((map) => map !== void 0);
17444
+ if (seen.size === 0 || answered) return;
17445
+ warn(`coverage: no stored source map answered for any of the ${seen.size} script(s) this run asked about, under commit ${deployedSha.slice(0, 12)}. Either that deploy pushed none, or the hub's deploy log names a commit the environment is no longer serving`);
17446
+ }
17383
17447
  };
17384
17448
  }
17385
17449
  /**
@@ -17460,8 +17524,17 @@ async function upsertMeasuredEdges(hubCtx, specs) {
17460
17524
  warn(`coverage: could not record measured edges on the hub (${errMessage(error)})`);
17461
17525
  }
17462
17526
  }
17527
+ /**
17528
+ * Whether the browser half came back with nothing anywhere. `every` rather
17529
+ * than `some`: a run that resolved some chunks from the copies the server
17530
+ * served has no gap the stored maps were needed for, and warning there would
17531
+ * fire on every build that strips maps from a few chunks and not the rest.
17532
+ */
17533
+ function noFrontendResolved(rows) {
17534
+ return rows.every((row) => (row.coverage?.frontendFiles ?? 0) === 0);
17535
+ }
17463
17536
  /** Everything the measurement could not place; silence here reads as "never reached". */
17464
- function reportCoverageHealth(coverage, rows) {
17537
+ function reportCoverageHealth(coverage, rows, storedMaps) {
17465
17538
  if (!coverage.heardFromApplication()) warn("no instrumented application process reported — only the browser half was measured. The server needs ccqa-tools and CCQA_COVERAGE_ENDPOINT pointed at this run's sink");
17466
17539
  if (rows.filter((row) => row.coverage !== void 0).length > 0 && coverage.heardFromApplication() && coverage.attributedSpecs() === 0) warn("instrumented application processes reported, but no spec was attributed to them — the spec cookie is not reaching the application, so every spec's server-side reach is zero for that reason rather than because the server ran nothing. Check coverage.instrumentedOrigins covers every origin the spec's requests go to, and that a server which is not node:http-based installs the middleware itself");
17467
17540
  const boot = coverage.boot();
@@ -17475,6 +17548,7 @@ function reportCoverageHealth(coverage, rows) {
17475
17548
  if (rejected > 0) warn(`${rejected} coverage push(es) named a spec id this run never issued — dropped`);
17476
17549
  const malformed = coverage.malformedPushes();
17477
17550
  if (malformed > 0) warn(`${malformed} coverage push(es) could not be read — the application's ccqa-tools and this CLI disagree on the wire format`);
17551
+ if (noFrontendResolved(rows)) storedMaps?.warnIfNothingAnswered();
17478
17552
  }
17479
17553
  function buildLiveRunSummary(results) {
17480
17554
  const sections = [];
@@ -23587,6 +23661,36 @@ function createResolveMemo(limit) {
23587
23661
  };
23588
23662
  }
23589
23663
  /**
23664
+ * `runId`'s answer, in two more scans: one keeping its markers, one replaying
23665
+ * the stream through the resolver they seed. Both stop at `head`, where the
23666
+ * scan that chose the run stopped — the resolver's markers are frozen there,
23667
+ * so a later push would credit a spec it was never told had opened and count
23668
+ * as rejected: a fault in the read, reading as a fault in the run.
23669
+ */
23670
+ /** A marker `runId` issued — what `StreamResolution` needs before it is fed the stream. */
23671
+ function isMarkerOf(event, runId) {
23672
+ const body = event.body;
23673
+ return "kind" in body && body.runId === runId;
23674
+ }
23675
+ async function resolveRun(config, key, project, runId, head, collected) {
23676
+ async function scanToHead(visit) {
23677
+ await scanStream(config, key, project, 0, (event) => {
23678
+ if (event.seq <= head) visit(event);
23679
+ });
23680
+ }
23681
+ let markers = collected;
23682
+ if (markers === null) {
23683
+ const found = [];
23684
+ await scanToHead((event) => {
23685
+ if (isMarkerOf(event, runId)) found.push(event);
23686
+ });
23687
+ markers = found;
23688
+ }
23689
+ const resolution = new StreamResolution(markers, runId);
23690
+ await scanToHead((event) => resolution.accept(event));
23691
+ return resolution.finish();
23692
+ }
23693
+ /**
23590
23694
  * GET /api/v1/coverage?project=[&runId=] — the stream, interpreted for one
23591
23695
  * run by the shared resolver (resolve-stream.ts); this handler only reads,
23592
23696
  * memoizes and serves. `runId` omitted means the run the stream most
@@ -23607,24 +23711,18 @@ function createResolveCoverageHandler(config) {
23607
23711
  sendJson(ctx.res, 200, hit);
23608
23712
  return;
23609
23713
  }
23610
- const markers = [];
23714
+ const runIndex = new RunIdIndex();
23715
+ const named = runKey !== "" ? [] : null;
23611
23716
  let seen = 0;
23612
23717
  const first = await scanStream(config, key, project, 0, (event) => {
23613
23718
  seen += 1;
23614
- if ("kind" in event.body) markers.push(event);
23719
+ runIndex.accept(event);
23720
+ if (named !== null && isMarkerOf(event, runKey)) named.push(event);
23615
23721
  });
23616
- const runIds = listRunIds(markers).slice(0, RUN_IDS_LIMIT);
23722
+ const runIds = runIndex.newestFirst().slice(0, RUN_IDS_LIMIT);
23617
23723
  const runId = runKey !== "" ? runKey : runIds[0];
23618
- let resolved = null;
23619
- if (runId !== void 0 && seen > 0) {
23620
- const resolution = new StreamResolution(markers, runId);
23621
- await scanStream(config, key, project, 0, (event) => {
23622
- if (event.seq <= first.lastSeq) resolution.accept(event);
23623
- });
23624
- resolved = resolution.finish();
23625
- }
23626
23724
  const answer = {
23627
- resolved,
23725
+ resolved: runId !== void 0 && seen > 0 ? await resolveRun(config, key, project, runId, first.lastSeq, named) : null,
23628
23726
  runIds
23629
23727
  };
23630
23728
  memo.put(project, runKey, first.lastSeq, answer);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.44.0",
3
+ "version": "1.45.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.44.0",
3
+ "version": "1.45.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {