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 +149 -51
- package/dist/package.json +1 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
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
|
|
12470
|
-
|
|
12471
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
*
|
|
17365
|
-
*
|
|
17366
|
-
*
|
|
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
|
|
17378
|
-
|
|
17379
|
-
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
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
|
|
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
|
-
|
|
23719
|
+
runIndex.accept(event);
|
|
23720
|
+
if (named !== null && isMarkerOf(event, runKey)) named.push(event);
|
|
23615
23721
|
});
|
|
23616
|
-
const runIds =
|
|
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