ccqa 1.43.0 → 1.45.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.
- package/dist/bin/ccqa.mjs +334 -174
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
|
|
|
4
4
|
import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-Bm34WBui.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { Command } from "commander";
|
|
7
|
-
import { accessSync, appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { accessSync, appendFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
10
|
import { access, appendFile, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
@@ -11986,6 +11986,14 @@ async function resolveCoverageRoots(changed, cwd) {
|
|
|
11986
11986
|
*/
|
|
11987
11987
|
const MAX_REPORT_RUNS = 20;
|
|
11988
11988
|
/**
|
|
11989
|
+
* How many reads one source keeps in flight. The two sources run together, so
|
|
11990
|
+
* the hub sees twice this. All at once is what it cannot take: it serves one
|
|
11991
|
+
* process, a resolve walks the whole event stream, and a report carries its
|
|
11992
|
+
* screenshots inline — forty of those together is what took it down, rather
|
|
11993
|
+
* than any single one of them.
|
|
11994
|
+
*/
|
|
11995
|
+
const HUB_READ_CONCURRENCY = 4;
|
|
11996
|
+
/**
|
|
11989
11997
|
* Read every spec's most recent measured reach from the hub. Never throws: a
|
|
11990
11998
|
* source that cannot be read warns, and `degraded` flips when the failure
|
|
11991
11999
|
* leaves absence ambiguous (the ledger itself, or the legacy sources while
|
|
@@ -12053,8 +12061,14 @@ async function collectStreamEdges(input, merge) {
|
|
|
12053
12061
|
const { hub, project } = input;
|
|
12054
12062
|
const latest = await hub.getCoverage(project);
|
|
12055
12063
|
ingestResolved(latest.resolved, merge);
|
|
12056
|
-
|
|
12057
|
-
|
|
12064
|
+
return (await runPool(latest.runIds.filter((runId) => runId !== latest.resolved?.runId), HUB_READ_CONCURRENCY, async (runId) => {
|
|
12065
|
+
try {
|
|
12066
|
+
ingestResolved((await hub.getCoverage(project, { runId })).resolved, merge);
|
|
12067
|
+
return true;
|
|
12068
|
+
} catch {
|
|
12069
|
+
return false;
|
|
12070
|
+
}
|
|
12071
|
+
})).filter((ok) => !ok).length;
|
|
12058
12072
|
}
|
|
12059
12073
|
function ingestResolved(resolved, merge) {
|
|
12060
12074
|
if (!resolved) return;
|
|
@@ -12086,7 +12100,7 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
|
|
|
12086
12100
|
*/
|
|
12087
12101
|
async function collectReportEdges(input, merge) {
|
|
12088
12102
|
const { hub, project } = input;
|
|
12089
|
-
|
|
12103
|
+
return (await runPool((await hub.listRuns({
|
|
12090
12104
|
project,
|
|
12091
12105
|
kind: "run",
|
|
12092
12106
|
limit: MAX_REPORT_RUNS
|
|
@@ -12098,18 +12112,22 @@ async function collectReportEdges(input, merge) {
|
|
|
12098
12112
|
id: run.id,
|
|
12099
12113
|
measuredAt
|
|
12100
12114
|
}];
|
|
12101
|
-
})
|
|
12102
|
-
|
|
12103
|
-
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
|
|
12107
|
-
|
|
12108
|
-
|
|
12109
|
-
|
|
12110
|
-
|
|
12115
|
+
}), HUB_READ_CONCURRENCY, async ({ id, measuredAt }) => {
|
|
12116
|
+
try {
|
|
12117
|
+
const parsed = ReportCoverageRowsSchema.safeParse(await hub.getReport(id));
|
|
12118
|
+
if (!parsed.success) return true;
|
|
12119
|
+
for (const row of parsed.data.results) {
|
|
12120
|
+
if (!row.coverage || row.coverage.files.length === 0) continue;
|
|
12121
|
+
merge(`${row.feature}/${row.spec}`, {
|
|
12122
|
+
files: row.coverage.files,
|
|
12123
|
+
measuredAt
|
|
12124
|
+
});
|
|
12125
|
+
}
|
|
12126
|
+
return true;
|
|
12127
|
+
} catch {
|
|
12128
|
+
return false;
|
|
12111
12129
|
}
|
|
12112
|
-
}))
|
|
12130
|
+
})).filter((ok) => !ok).length;
|
|
12113
12131
|
}
|
|
12114
12132
|
//#endregion
|
|
12115
12133
|
//#region src/select/inventory.ts
|
|
@@ -12224,6 +12242,27 @@ function foldTouchIndex(current, entry, selection) {
|
|
|
12224
12242
|
}
|
|
12225
12243
|
return out;
|
|
12226
12244
|
}
|
|
12245
|
+
//#endregion
|
|
12246
|
+
//#region src/coverage/resolve-stream.ts
|
|
12247
|
+
/**
|
|
12248
|
+
* One run's answer out of a project's stored event stream (ADR-0022).
|
|
12249
|
+
*
|
|
12250
|
+
* The stream interleaves many runs and two producers; this is the single
|
|
12251
|
+
* shared interpretation over it — the same gate, join and loss accounting the
|
|
12252
|
+
* run-local sink applies, executed at read time by whichever host asks (the
|
|
12253
|
+
* hub's API, or the CLI). Everything here is a pure fold over the stamps the
|
|
12254
|
+
* events carry: no clock is ever read, so the same stream resolves to the
|
|
12255
|
+
* same answer on any host, at any time.
|
|
12256
|
+
*/
|
|
12257
|
+
/**
|
|
12258
|
+
* How far past a run's last marker an application push still counts as its
|
|
12259
|
+
* audience (ms). The run-local sink keeps listening while a spec settles, so
|
|
12260
|
+
* the work a spec's last action triggered still lands; a stored stream has no
|
|
12261
|
+
* listener to wait, so the resolve re-creates that patience as a fixed bound.
|
|
12262
|
+
* Pushes past it were heard by nobody bound to this run — most likely they
|
|
12263
|
+
* belong to whatever run came next.
|
|
12264
|
+
*/
|
|
12265
|
+
const GRACE_MS = 3e4;
|
|
12227
12266
|
z.object({
|
|
12228
12267
|
runId: z.string(),
|
|
12229
12268
|
hubRunId: z.string().optional(),
|
|
@@ -12261,88 +12300,109 @@ function formatResolvedSpec(spec) {
|
|
|
12261
12300
|
return `${spec.specId}: ${spec.files.length} file(s)${actors ? ` (${actors})` : ""}`;
|
|
12262
12301
|
}
|
|
12263
12302
|
/**
|
|
12264
|
-
*
|
|
12303
|
+
* `runId`'s view of the stream, built one event at a time.
|
|
12265
12304
|
*
|
|
12266
|
-
* Two passes, because the resolver needs its context up front
|
|
12305
|
+
* Two passes, because the resolver needs its context up front. The first
|
|
12267
12306
|
* collects what the run's own markers establish — which ids it issued, which
|
|
12268
12307
|
* identity tags were its to hand out, its universe, and when its first and
|
|
12269
|
-
* last marker arrived
|
|
12270
|
-
*
|
|
12271
|
-
*
|
|
12272
|
-
*
|
|
12273
|
-
*
|
|
12274
|
-
*
|
|
12275
|
-
* this run's to claim — but the
|
|
12276
|
-
* run acked, so on an always-on hub
|
|
12277
|
-
* figures arrived long before this run
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12284
|
-
|
|
12285
|
-
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
12291
|
-
|
|
12292
|
-
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
|
|
12308
|
+
* last marker arrived; markers are a small part of a stream, so a caller can
|
|
12309
|
+
* hold them. The second replays the stream through the shared resolver: this
|
|
12310
|
+
* run's window markers as they came, and every application push — as-is when
|
|
12311
|
+
* its stamp falls inside the span the run's sink would have been listening
|
|
12312
|
+
* (first marker to last marker plus `GRACE_MS`), stripped of its spec and
|
|
12313
|
+
* actor attribution when it does not. A push outside the span was another
|
|
12314
|
+
* run's audience, so its attribution is not this run's to claim — but the
|
|
12315
|
+
* collector never re-sends what an earlier run acked, so on an always-on hub
|
|
12316
|
+
* the boot set and each process's health figures arrived long before this run
|
|
12317
|
+
* began, and only survive here.
|
|
12318
|
+
*
|
|
12319
|
+
* The constructor takes the markers and `accept` takes one event at a time, so
|
|
12320
|
+
* the second pass can be fed from a stream: it keeps nothing per push, which
|
|
12321
|
+
* is what lets a resolve outlive the point where the stream stops fitting in
|
|
12322
|
+
* memory.
|
|
12323
|
+
*/
|
|
12324
|
+
var StreamResolution = class {
|
|
12325
|
+
runId;
|
|
12326
|
+
resolver;
|
|
12327
|
+
/** The ids this run opened, in that order — a `Set` iterates by insertion. */
|
|
12328
|
+
specIds;
|
|
12329
|
+
browserFiles = /* @__PURE__ */ new Map();
|
|
12330
|
+
/**
|
|
12331
|
+
* The stamps in which the run's sink would have been listening. Undefined
|
|
12332
|
+
* when the markers held no event of this run — then no push is ever inside.
|
|
12333
|
+
*/
|
|
12334
|
+
span;
|
|
12335
|
+
universe;
|
|
12336
|
+
hubRunId;
|
|
12337
|
+
asOf = 0;
|
|
12338
|
+
lastSeq = 0;
|
|
12339
|
+
pushesDuringRun = 0;
|
|
12340
|
+
/** `markers` needs to hold every marker `runId` issued; other runs' markers and pushes are ignored. */
|
|
12341
|
+
constructor(markers, runId) {
|
|
12342
|
+
this.runId = runId;
|
|
12343
|
+
const specIds = /* @__PURE__ */ new Set();
|
|
12344
|
+
const tagToKey = /* @__PURE__ */ new Map();
|
|
12345
|
+
let firstAt;
|
|
12346
|
+
let lastAt = 0;
|
|
12347
|
+
for (const event of markers) {
|
|
12348
|
+
const body = event.body;
|
|
12349
|
+
if (!("kind" in body) || body.runId !== runId) continue;
|
|
12350
|
+
if (firstAt === void 0) firstAt = event.at;
|
|
12351
|
+
lastAt = event.at;
|
|
12352
|
+
switch (body.kind) {
|
|
12353
|
+
case "spec-open":
|
|
12354
|
+
specIds.add(body.specId);
|
|
12355
|
+
break;
|
|
12356
|
+
case "window-open":
|
|
12357
|
+
tagToKey.set(body.tag, body.key);
|
|
12358
|
+
break;
|
|
12359
|
+
case "universe":
|
|
12360
|
+
this.universe = {
|
|
12361
|
+
include: body.include,
|
|
12362
|
+
files: body.files
|
|
12363
|
+
};
|
|
12364
|
+
break;
|
|
12365
|
+
case "run-link":
|
|
12366
|
+
this.hubRunId = body.hubRunId;
|
|
12367
|
+
break;
|
|
12368
|
+
case "browser": {
|
|
12369
|
+
const files = this.browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
|
|
12370
|
+
for (const file of body.files) files.add(file);
|
|
12371
|
+
this.browserFiles.set(body.specId, files);
|
|
12372
|
+
break;
|
|
12298
12373
|
}
|
|
12299
|
-
break;
|
|
12300
|
-
case "window-open":
|
|
12301
|
-
tagToKey.set(body.tag, body.key);
|
|
12302
|
-
break;
|
|
12303
|
-
case "universe":
|
|
12304
|
-
universe = {
|
|
12305
|
-
include: body.include,
|
|
12306
|
-
files: body.files
|
|
12307
|
-
};
|
|
12308
|
-
break;
|
|
12309
|
-
case "run-link":
|
|
12310
|
-
hubRunId = body.hubRunId;
|
|
12311
|
-
break;
|
|
12312
|
-
case "browser": {
|
|
12313
|
-
const files = browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
|
|
12314
|
-
for (const file of body.files) files.add(file);
|
|
12315
|
-
browserFiles.set(body.specId, files);
|
|
12316
|
-
break;
|
|
12317
12374
|
}
|
|
12318
12375
|
}
|
|
12376
|
+
this.specIds = specIds;
|
|
12377
|
+
this.span = firstAt === void 0 ? void 0 : {
|
|
12378
|
+
from: firstAt,
|
|
12379
|
+
until: lastAt + GRACE_MS
|
|
12380
|
+
};
|
|
12381
|
+
this.resolver = new CoverageResolver(specIds, tagToKey);
|
|
12319
12382
|
}
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
let pushesDuringRun = 0;
|
|
12324
|
-
for (const event of events) {
|
|
12325
|
-
if (event.seq > lastSeq) lastSeq = event.seq;
|
|
12383
|
+
/** One event of the second pass. Every event of the stream, in stamp order. */
|
|
12384
|
+
accept(event) {
|
|
12385
|
+
if (event.seq > this.lastSeq) this.lastSeq = event.seq;
|
|
12326
12386
|
const body = event.body;
|
|
12327
12387
|
if ("kind" in body) {
|
|
12328
|
-
if (body.runId !== runId)
|
|
12329
|
-
asOf = event.at;
|
|
12330
|
-
if (body.kind === "window-open") resolver.apply({
|
|
12388
|
+
if (body.runId !== this.runId) return;
|
|
12389
|
+
this.asOf = event.at;
|
|
12390
|
+
if (body.kind === "window-open") this.resolver.apply({
|
|
12331
12391
|
kind: "window-open",
|
|
12332
12392
|
at: event.at,
|
|
12333
12393
|
tag: body.tag,
|
|
12334
12394
|
key: body.key,
|
|
12335
12395
|
specId: body.specId
|
|
12336
12396
|
});
|
|
12337
|
-
else if (body.kind === "window-close") resolver.apply({
|
|
12397
|
+
else if (body.kind === "window-close") this.resolver.apply({
|
|
12338
12398
|
kind: "window-close",
|
|
12339
12399
|
at: event.at,
|
|
12340
12400
|
tag: body.tag
|
|
12341
12401
|
});
|
|
12342
|
-
|
|
12402
|
+
return;
|
|
12343
12403
|
}
|
|
12344
|
-
if (
|
|
12345
|
-
resolver.apply({
|
|
12404
|
+
if (this.span === void 0 || event.at < this.span.from || event.at > this.span.until) {
|
|
12405
|
+
this.resolver.apply({
|
|
12346
12406
|
kind: "push",
|
|
12347
12407
|
at: event.at,
|
|
12348
12408
|
push: {
|
|
@@ -12351,62 +12411,71 @@ function resolveStream(events, runId) {
|
|
|
12351
12411
|
actors: []
|
|
12352
12412
|
}
|
|
12353
12413
|
});
|
|
12354
|
-
|
|
12414
|
+
return;
|
|
12355
12415
|
}
|
|
12356
|
-
asOf = event.at;
|
|
12357
|
-
pushesDuringRun++;
|
|
12358
|
-
resolver.apply({
|
|
12416
|
+
this.asOf = event.at;
|
|
12417
|
+
this.pushesDuringRun++;
|
|
12418
|
+
this.resolver.apply({
|
|
12359
12419
|
kind: "push",
|
|
12360
12420
|
at: event.at,
|
|
12361
12421
|
push: body
|
|
12362
12422
|
});
|
|
12363
12423
|
}
|
|
12364
|
-
|
|
12365
|
-
|
|
12366
|
-
|
|
12424
|
+
/** The answer as of every event accepted so far. */
|
|
12425
|
+
finish() {
|
|
12426
|
+
const specs = [...this.specIds].map((specId) => {
|
|
12427
|
+
const actorEvents = {};
|
|
12428
|
+
for (const [key, count] of this.resolver.actorEventsFor(specId)) actorEvents[key] = count;
|
|
12429
|
+
return {
|
|
12430
|
+
specId,
|
|
12431
|
+
files: [...new Set([...this.resolver.filesFor(specId) ?? [], ...this.browserFiles.get(specId) ?? []])].sort(),
|
|
12432
|
+
actorEvents
|
|
12433
|
+
};
|
|
12434
|
+
});
|
|
12435
|
+
const outsideWindowEvents = {};
|
|
12436
|
+
for (const [key, count] of this.resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
|
|
12367
12437
|
return {
|
|
12368
|
-
|
|
12369
|
-
|
|
12370
|
-
|
|
12438
|
+
runId: this.runId,
|
|
12439
|
+
...this.hubRunId !== void 0 ? { hubRunId: this.hubRunId } : {},
|
|
12440
|
+
asOf: this.asOf,
|
|
12441
|
+
lastSeq: this.lastSeq,
|
|
12442
|
+
...this.universe !== void 0 ? { universe: this.universe } : {},
|
|
12443
|
+
specs,
|
|
12444
|
+
boot: [...this.resolver.boot()].sort(),
|
|
12445
|
+
health: {
|
|
12446
|
+
heardFromApplication: this.resolver.heardFromApplication(),
|
|
12447
|
+
pushesDuringRun: this.pushesDuringRun,
|
|
12448
|
+
attributedSpecs: this.resolver.attributedSpecs(),
|
|
12449
|
+
rejectedPushes: this.resolver.rejectedPushes(),
|
|
12450
|
+
uninstrumentedFiles: this.resolver.uninstrumentedFiles(),
|
|
12451
|
+
uninstrumentedProcesses: this.resolver.uninstrumentedProcesses(),
|
|
12452
|
+
droppedPushes: this.resolver.droppedPushes(),
|
|
12453
|
+
unmappedActorEvents: this.resolver.unmappedActorEvents(),
|
|
12454
|
+
outsideWindowEvents,
|
|
12455
|
+
specsMeasured: specs.length
|
|
12456
|
+
}
|
|
12371
12457
|
};
|
|
12372
|
-
}
|
|
12373
|
-
|
|
12374
|
-
for (const [key, count] of resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
|
|
12375
|
-
return {
|
|
12376
|
-
runId,
|
|
12377
|
-
...hubRunId !== void 0 ? { hubRunId } : {},
|
|
12378
|
-
asOf,
|
|
12379
|
-
lastSeq,
|
|
12380
|
-
...universe !== void 0 ? { universe } : {},
|
|
12381
|
-
specs,
|
|
12382
|
-
boot: [...resolver.boot()].sort(),
|
|
12383
|
-
health: {
|
|
12384
|
-
heardFromApplication: resolver.heardFromApplication(),
|
|
12385
|
-
pushesDuringRun,
|
|
12386
|
-
attributedSpecs: resolver.attributedSpecs(),
|
|
12387
|
-
rejectedPushes: resolver.rejectedPushes(),
|
|
12388
|
-
uninstrumentedFiles: resolver.uninstrumentedFiles(),
|
|
12389
|
-
uninstrumentedProcesses: resolver.uninstrumentedProcesses(),
|
|
12390
|
-
droppedPushes: resolver.droppedPushes(),
|
|
12391
|
-
unmappedActorEvents: resolver.unmappedActorEvents(),
|
|
12392
|
-
outsideWindowEvents,
|
|
12393
|
-
specsMeasured: specs.length
|
|
12394
|
-
}
|
|
12395
|
-
};
|
|
12396
|
-
}
|
|
12458
|
+
}
|
|
12459
|
+
};
|
|
12397
12460
|
/**
|
|
12398
12461
|
* Every run that opened a spec in this stream, most recently heard-from
|
|
12399
12462
|
* first — recency by the arrival position of each run's latest spec-open,
|
|
12400
|
-
* the one order the hub's stamps establish.
|
|
12463
|
+
* the one order the hub's stamps establish. Fed one event at a time, so the
|
|
12464
|
+
* question can be asked of a stream too large to hold.
|
|
12401
12465
|
*/
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12466
|
+
var RunIdIndex = class {
|
|
12467
|
+
/** Re-inserted on every `spec-open`, so insertion order is order of latest open. */
|
|
12468
|
+
opened = /* @__PURE__ */ new Set();
|
|
12469
|
+
accept(event) {
|
|
12405
12470
|
const body = event.body;
|
|
12406
|
-
if ("kind" in body
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
}
|
|
12471
|
+
if (!("kind" in body) || body.kind !== "spec-open") return;
|
|
12472
|
+
this.opened.delete(body.runId);
|
|
12473
|
+
this.opened.add(body.runId);
|
|
12474
|
+
}
|
|
12475
|
+
newestFirst() {
|
|
12476
|
+
return [...this.opened].reverse();
|
|
12477
|
+
}
|
|
12478
|
+
};
|
|
12410
12479
|
//#endregion
|
|
12411
12480
|
//#region src/cli/session.ts
|
|
12412
12481
|
const AB = resolveAgentBrowserBin$1();
|
|
@@ -17065,7 +17134,8 @@ async function executeRun(targets, opts) {
|
|
|
17065
17134
|
const liveSpecs = withMode.filter((s) => s.mode === "live");
|
|
17066
17135
|
meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
|
|
17067
17136
|
if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
|
|
17068
|
-
const
|
|
17137
|
+
const storedMaps = hubCtx !== null && deployedSha !== null ? createStoredSourceMaps(hubCtx, deployedSha) : void 0;
|
|
17138
|
+
const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedMaps?.read, hubCtx !== null) : void 0;
|
|
17069
17139
|
if (liveSpecs.length === 0) {
|
|
17070
17140
|
const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
|
|
17071
17141
|
if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
|
|
@@ -17178,8 +17248,11 @@ async function executeRun(targets, opts) {
|
|
|
17178
17248
|
};
|
|
17179
17249
|
const live = await runLiveSpecs(liveSpecs, liveOpts);
|
|
17180
17250
|
let streamedEdges = {};
|
|
17181
|
-
if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
|
|
17182
|
-
else if (coverage && hubCtx != null)
|
|
17251
|
+
if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults], storedMaps);
|
|
17252
|
+
else if (coverage && hubCtx != null) {
|
|
17253
|
+
streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
|
|
17254
|
+
storedMaps?.warnIfNothingAnswered();
|
|
17255
|
+
}
|
|
17183
17256
|
let overallExitCode = det.exitCode !== 0 ? 1 : 0;
|
|
17184
17257
|
if (live.failedCount > 0) overallExitCode = 1;
|
|
17185
17258
|
if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
|
|
@@ -17273,6 +17346,12 @@ async function executeRun(targets, opts) {
|
|
|
17273
17346
|
reportDir
|
|
17274
17347
|
};
|
|
17275
17348
|
}
|
|
17349
|
+
/**
|
|
17350
|
+
* Starts the run's coverage measurement before any spec runs: the sink has to
|
|
17351
|
+
* be listening before the first request reaches the application, and the set
|
|
17352
|
+
* of spec ids it will accept is only known once dispatch has resolved. With
|
|
17353
|
+
* an `inbox`, nothing binds — the run streams its events to the hub instead.
|
|
17354
|
+
*/
|
|
17276
17355
|
async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fetchStoredSourceMap, hubConfigured) {
|
|
17277
17356
|
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");
|
|
17278
17357
|
let session;
|
|
@@ -17298,25 +17377,25 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fet
|
|
|
17298
17377
|
return session;
|
|
17299
17378
|
}
|
|
17300
17379
|
/**
|
|
17301
|
-
*
|
|
17302
|
-
*
|
|
17303
|
-
*
|
|
17304
|
-
* an `inbox`, nothing binds — the run streams its events to the hub instead.
|
|
17305
|
-
*/
|
|
17306
|
-
/**
|
|
17307
|
-
* Needs both a hub and the commit under test: without either there is nothing
|
|
17308
|
-
* to address a stored map by, and reading some other commit's maps would name
|
|
17309
|
-
* the wrong files. See `SourceMapStore`.
|
|
17380
|
+
* The maps a deploy pushed for one commit. One answer per asset for the run:
|
|
17381
|
+
* the commit is fixed, so a miss stays a miss, and every spec would otherwise
|
|
17382
|
+
* re-ask for the same scripts.
|
|
17310
17383
|
*/
|
|
17311
|
-
function
|
|
17312
|
-
if (hubCtx === null || deployedSha === null) return void 0;
|
|
17384
|
+
function createStoredSourceMaps(hubCtx, deployedSha) {
|
|
17313
17385
|
const seen = /* @__PURE__ */ new Map();
|
|
17314
|
-
return
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17386
|
+
return {
|
|
17387
|
+
read: async (assetPath) => {
|
|
17388
|
+
const cached = seen.get(assetPath);
|
|
17389
|
+
if (cached !== void 0 || seen.has(assetPath)) return cached;
|
|
17390
|
+
const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
|
|
17391
|
+
seen.set(assetPath, map);
|
|
17392
|
+
return map;
|
|
17393
|
+
},
|
|
17394
|
+
warnIfNothingAnswered() {
|
|
17395
|
+
const answered = [...seen.values()].some((map) => map !== void 0);
|
|
17396
|
+
if (seen.size === 0 || answered) return;
|
|
17397
|
+
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`);
|
|
17398
|
+
}
|
|
17320
17399
|
};
|
|
17321
17400
|
}
|
|
17322
17401
|
/**
|
|
@@ -17397,8 +17476,17 @@ async function upsertMeasuredEdges(hubCtx, specs) {
|
|
|
17397
17476
|
warn(`coverage: could not record measured edges on the hub (${errMessage(error)})`);
|
|
17398
17477
|
}
|
|
17399
17478
|
}
|
|
17479
|
+
/**
|
|
17480
|
+
* Whether the browser half came back with nothing anywhere. `every` rather
|
|
17481
|
+
* than `some`: a run that resolved some chunks from the copies the server
|
|
17482
|
+
* served has no gap the stored maps were needed for, and warning there would
|
|
17483
|
+
* fire on every build that strips maps from a few chunks and not the rest.
|
|
17484
|
+
*/
|
|
17485
|
+
function noFrontendResolved(rows) {
|
|
17486
|
+
return rows.every((row) => (row.coverage?.frontendFiles ?? 0) === 0);
|
|
17487
|
+
}
|
|
17400
17488
|
/** Everything the measurement could not place; silence here reads as "never reached". */
|
|
17401
|
-
function reportCoverageHealth(coverage, rows) {
|
|
17489
|
+
function reportCoverageHealth(coverage, rows, storedMaps) {
|
|
17402
17490
|
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");
|
|
17403
17491
|
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");
|
|
17404
17492
|
const boot = coverage.boot();
|
|
@@ -17412,6 +17500,7 @@ function reportCoverageHealth(coverage, rows) {
|
|
|
17412
17500
|
if (rejected > 0) warn(`${rejected} coverage push(es) named a spec id this run never issued — dropped`);
|
|
17413
17501
|
const malformed = coverage.malformedPushes();
|
|
17414
17502
|
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`);
|
|
17503
|
+
if (noFrontendResolved(rows)) storedMaps?.warnIfNothingAnswered();
|
|
17415
17504
|
}
|
|
17416
17505
|
function buildLiveRunSummary(results) {
|
|
17417
17506
|
const sections = [];
|
|
@@ -23432,7 +23521,9 @@ function createAppendCoverageEventHandler(config) {
|
|
|
23432
23521
|
}
|
|
23433
23522
|
/**
|
|
23434
23523
|
* GET /api/v1/coverage/events?project=&sinceSeq= — the stream after `sinceSeq`
|
|
23435
|
-
* (exclusive,
|
|
23524
|
+
* (exclusive), decrypted, at most `MAX_EVENTS_PER_READ` of them. A consumer
|
|
23525
|
+
* passes back the `seq` of the last event it received; `lastSeq` is the
|
|
23526
|
+
* stream's head, which `truncated` says the body stopped short of.
|
|
23436
23527
|
* Hub bearer token only (the server's central check): what the append-only
|
|
23437
23528
|
* credential wrote, it must not be able to read back.
|
|
23438
23529
|
*/
|
|
@@ -23441,33 +23532,51 @@ function createGetCoverageEventsHandler(config) {
|
|
|
23441
23532
|
const key = requireKey(config);
|
|
23442
23533
|
const project = requireProjectParam(ctx);
|
|
23443
23534
|
const sinceSeq = requireSinceSeqParam(ctx.url);
|
|
23444
|
-
|
|
23535
|
+
const events = [];
|
|
23536
|
+
let truncated = false;
|
|
23537
|
+
const { lastSeq, skipped } = await scanStream(config, key, project, sinceSeq, (event) => {
|
|
23538
|
+
if (events.length < MAX_EVENTS_PER_READ) events.push(event);
|
|
23539
|
+
else truncated = true;
|
|
23540
|
+
});
|
|
23541
|
+
sendJson(ctx.res, 200, {
|
|
23542
|
+
events,
|
|
23543
|
+
lastSeq,
|
|
23544
|
+
skipped,
|
|
23545
|
+
truncated
|
|
23546
|
+
});
|
|
23445
23547
|
};
|
|
23446
23548
|
}
|
|
23447
|
-
/**
|
|
23448
|
-
|
|
23449
|
-
|
|
23450
|
-
|
|
23549
|
+
/**
|
|
23550
|
+
* Hand each event after `sinceSeq` (exclusive) to `visit`, decrypted and
|
|
23551
|
+
* parsed. Nothing is retained here: what a caller keeps is its own choice,
|
|
23552
|
+
* which is what lets a whole-stream read stay bounded.
|
|
23553
|
+
*/
|
|
23554
|
+
async function scanStream(config, key, project, sinceSeq, visit) {
|
|
23451
23555
|
let unreadable = 0;
|
|
23452
|
-
|
|
23453
|
-
|
|
23454
|
-
|
|
23455
|
-
|
|
23456
|
-
|
|
23457
|
-
|
|
23458
|
-
|
|
23459
|
-
|
|
23460
|
-
|
|
23461
|
-
|
|
23462
|
-
|
|
23556
|
+
const { lastSeq, skipped } = await config.store.scan(project, sinceSeq, (entry) => {
|
|
23557
|
+
let event;
|
|
23558
|
+
try {
|
|
23559
|
+
const plain = decrypt(decodeEncryptedBlob(entry.payload), key);
|
|
23560
|
+
event = {
|
|
23561
|
+
seq: entry.seq,
|
|
23562
|
+
at: entry.at,
|
|
23563
|
+
body: InboxBodySchema.parse(JSON.parse(new TextDecoder().decode(plain)))
|
|
23564
|
+
};
|
|
23565
|
+
} catch {
|
|
23566
|
+
unreadable += 1;
|
|
23567
|
+
return;
|
|
23568
|
+
}
|
|
23569
|
+
visit(event);
|
|
23570
|
+
});
|
|
23463
23571
|
return {
|
|
23464
|
-
events,
|
|
23465
23572
|
lastSeq,
|
|
23466
23573
|
skipped: skipped + unreadable
|
|
23467
23574
|
};
|
|
23468
23575
|
}
|
|
23469
23576
|
/** Newest runs the resolve read offers; past twenty the answer is history nobody pages through. */
|
|
23470
23577
|
const RUN_IDS_LIMIT = 20;
|
|
23578
|
+
/** How many events one `GET /events` body carries; past it the answer is `truncated`. */
|
|
23579
|
+
const MAX_EVENTS_PER_READ = 5e3;
|
|
23471
23580
|
/** Resolved answers kept per handler; one page polls one key, so a handful covers the readers. */
|
|
23472
23581
|
const RESOLVE_CACHE_LIMIT = 8;
|
|
23473
23582
|
/**
|
|
@@ -23504,6 +23613,36 @@ function createResolveMemo(limit) {
|
|
|
23504
23613
|
};
|
|
23505
23614
|
}
|
|
23506
23615
|
/**
|
|
23616
|
+
* `runId`'s answer, in two more scans: one keeping its markers, one replaying
|
|
23617
|
+
* the stream through the resolver they seed. Both stop at `head`, where the
|
|
23618
|
+
* scan that chose the run stopped — the resolver's markers are frozen there,
|
|
23619
|
+
* so a later push would credit a spec it was never told had opened and count
|
|
23620
|
+
* as rejected: a fault in the read, reading as a fault in the run.
|
|
23621
|
+
*/
|
|
23622
|
+
/** A marker `runId` issued — what `StreamResolution` needs before it is fed the stream. */
|
|
23623
|
+
function isMarkerOf(event, runId) {
|
|
23624
|
+
const body = event.body;
|
|
23625
|
+
return "kind" in body && body.runId === runId;
|
|
23626
|
+
}
|
|
23627
|
+
async function resolveRun(config, key, project, runId, head, collected) {
|
|
23628
|
+
async function scanToHead(visit) {
|
|
23629
|
+
await scanStream(config, key, project, 0, (event) => {
|
|
23630
|
+
if (event.seq <= head) visit(event);
|
|
23631
|
+
});
|
|
23632
|
+
}
|
|
23633
|
+
let markers = collected;
|
|
23634
|
+
if (markers === null) {
|
|
23635
|
+
const found = [];
|
|
23636
|
+
await scanToHead((event) => {
|
|
23637
|
+
if (isMarkerOf(event, runId)) found.push(event);
|
|
23638
|
+
});
|
|
23639
|
+
markers = found;
|
|
23640
|
+
}
|
|
23641
|
+
const resolution = new StreamResolution(markers, runId);
|
|
23642
|
+
await scanToHead((event) => resolution.accept(event));
|
|
23643
|
+
return resolution.finish();
|
|
23644
|
+
}
|
|
23645
|
+
/**
|
|
23507
23646
|
* GET /api/v1/coverage?project=[&runId=] — the stream, interpreted for one
|
|
23508
23647
|
* run by the shared resolver (resolve-stream.ts); this handler only reads,
|
|
23509
23648
|
* memoizes and serves. `runId` omitted means the run the stream most
|
|
@@ -23524,14 +23663,21 @@ function createResolveCoverageHandler(config) {
|
|
|
23524
23663
|
sendJson(ctx.res, 200, hit);
|
|
23525
23664
|
return;
|
|
23526
23665
|
}
|
|
23527
|
-
const
|
|
23528
|
-
const
|
|
23666
|
+
const runIndex = new RunIdIndex();
|
|
23667
|
+
const named = runKey !== "" ? [] : null;
|
|
23668
|
+
let seen = 0;
|
|
23669
|
+
const first = await scanStream(config, key, project, 0, (event) => {
|
|
23670
|
+
seen += 1;
|
|
23671
|
+
runIndex.accept(event);
|
|
23672
|
+
if (named !== null && isMarkerOf(event, runKey)) named.push(event);
|
|
23673
|
+
});
|
|
23674
|
+
const runIds = runIndex.newestFirst().slice(0, RUN_IDS_LIMIT);
|
|
23529
23675
|
const runId = runKey !== "" ? runKey : runIds[0];
|
|
23530
23676
|
const answer = {
|
|
23531
|
-
resolved: runId
|
|
23677
|
+
resolved: runId !== void 0 && seen > 0 ? await resolveRun(config, key, project, runId, first.lastSeq, named) : null,
|
|
23532
23678
|
runIds
|
|
23533
23679
|
};
|
|
23534
|
-
memo.put(project, runKey, lastSeq, answer);
|
|
23680
|
+
memo.put(project, runKey, first.lastSeq, answer);
|
|
23535
23681
|
sendJson(ctx.res, 200, answer);
|
|
23536
23682
|
};
|
|
23537
23683
|
}
|
|
@@ -31168,7 +31314,7 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
31168
31314
|
oldestAt: null,
|
|
31169
31315
|
endsWithNewline: raw === "" || raw.endsWith("\n")
|
|
31170
31316
|
};
|
|
31171
|
-
for (const rawLine of
|
|
31317
|
+
for (const rawLine of raw.split("\n")) {
|
|
31172
31318
|
const line = parseLine(rawLine);
|
|
31173
31319
|
if (line === null) continue;
|
|
31174
31320
|
if (line.seq >= state.nextSeq) state.nextSeq = line.seq + 1;
|
|
@@ -31224,19 +31370,17 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
31224
31370
|
return stamp;
|
|
31225
31371
|
});
|
|
31226
31372
|
},
|
|
31227
|
-
async
|
|
31373
|
+
async scan(project, sinceSeq, visit) {
|
|
31228
31374
|
assertSafeName(project, "project");
|
|
31229
31375
|
const path = coverageEventsPath(root, project);
|
|
31230
31376
|
const now = Date.now();
|
|
31231
31377
|
await serialize(path, async () => {
|
|
31232
31378
|
await pruneIfDue(project, path, await loadState(project, path), now);
|
|
31233
31379
|
});
|
|
31234
|
-
const raw = await readRaw(path);
|
|
31235
31380
|
const cutoff = now - retentionMs;
|
|
31236
|
-
const entries = [];
|
|
31237
31381
|
let lastSeq = 0;
|
|
31238
31382
|
let skipped = 0;
|
|
31239
|
-
for (const rawLine of
|
|
31383
|
+
for await (const rawLine of streamLines(path)) {
|
|
31240
31384
|
const line = parseLine(rawLine);
|
|
31241
31385
|
if (line === null) {
|
|
31242
31386
|
skipped += 1;
|
|
@@ -31245,15 +31389,13 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
31245
31389
|
if (line.seq > lastSeq) lastSeq = line.seq;
|
|
31246
31390
|
if (line.seq <= sinceSeq) continue;
|
|
31247
31391
|
if (line.at < cutoff) continue;
|
|
31248
|
-
|
|
31392
|
+
visit({
|
|
31249
31393
|
seq: line.seq,
|
|
31250
31394
|
at: line.at,
|
|
31251
31395
|
payload: new Uint8Array(Buffer.from(line.payload, "base64"))
|
|
31252
31396
|
});
|
|
31253
31397
|
}
|
|
31254
|
-
entries.sort((a, b) => a.seq - b.seq);
|
|
31255
31398
|
return {
|
|
31256
|
-
entries,
|
|
31257
31399
|
lastSeq,
|
|
31258
31400
|
skipped
|
|
31259
31401
|
};
|
|
@@ -31284,13 +31426,31 @@ async function readRaw(path) {
|
|
|
31284
31426
|
throw err;
|
|
31285
31427
|
}
|
|
31286
31428
|
}
|
|
31287
|
-
|
|
31288
|
-
|
|
31429
|
+
/**
|
|
31430
|
+
* The stream's non-empty lines, one at a time. Streamed rather than read as one
|
|
31431
|
+
* string because a project's stream is capped in the hundreds of megabytes, far
|
|
31432
|
+
* past what a reader can hold. Order is the file's, which is append order,
|
|
31433
|
+
* which is seq order — the prune rewrites a suffix and never reorders.
|
|
31434
|
+
*/
|
|
31435
|
+
async function* streamLines(path) {
|
|
31436
|
+
const input = createReadStream(path, { encoding: "utf8" });
|
|
31437
|
+
const lines = createInterface$1({
|
|
31438
|
+
input,
|
|
31439
|
+
crlfDelay: Infinity
|
|
31440
|
+
});
|
|
31441
|
+
try {
|
|
31442
|
+
for await (const line of lines) if (line !== "") yield line;
|
|
31443
|
+
} catch (err) {
|
|
31444
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) throw err;
|
|
31445
|
+
} finally {
|
|
31446
|
+
lines.close();
|
|
31447
|
+
input.destroy();
|
|
31448
|
+
}
|
|
31289
31449
|
}
|
|
31290
31450
|
/** Every parseable line of the log; partial or corrupt lines are silently omitted (the read side counts them). */
|
|
31291
31451
|
async function readLines(path) {
|
|
31292
31452
|
const lines = [];
|
|
31293
|
-
for (const rawLine of
|
|
31453
|
+
for await (const rawLine of streamLines(path)) {
|
|
31294
31454
|
const line = parseLine(rawLine);
|
|
31295
31455
|
if (line !== null) lines.push(line);
|
|
31296
31456
|
}
|
package/dist/package.json
CHANGED