ccqa 1.36.0 → 1.37.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/README.md +1 -0
- package/dist/bin/ccqa.mjs +2737 -111
- package/dist/hub-client/index.d.mts +56 -0
- package/dist/package.json +1 -1
- package/dist/runtime/vitest.config.d.mts +311 -35
- package/package.json +4 -4
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -4,8 +4,8 @@ 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-CRIVfWpw.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { Command } from "commander";
|
|
7
|
-
import { accessSync, appendFileSync, createWriteStream, existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { accessSync, appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
10
|
import { access, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -17,9 +17,10 @@ import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk"
|
|
|
17
17
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
import { createInterface } from "node:readline/promises";
|
|
20
|
+
import { createServer } from "node:http";
|
|
20
21
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
21
22
|
import { createInterface as createInterface$1 } from "node:readline";
|
|
22
|
-
import { createServer } from "node:
|
|
23
|
+
import { createServer as createServer$1 } from "node:net";
|
|
23
24
|
//#region src/run/report-constants.ts
|
|
24
25
|
/**
|
|
25
26
|
* Pure report/run constants with no runtime dependencies. Kept separate from
|
|
@@ -185,14 +186,14 @@ const SpecModeSchema = z.enum(["deterministic", "live"]);
|
|
|
185
186
|
* A name a spec chooses that ccqa resolves to a path or looks up in a
|
|
186
187
|
* registry. Restricted to a slug so it cannot escape a directory.
|
|
187
188
|
*/
|
|
188
|
-
function slug(what) {
|
|
189
|
+
function slug$1(what) {
|
|
189
190
|
return z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
|
|
190
191
|
}
|
|
191
192
|
/**
|
|
192
193
|
* A saved browser session (cookies + localStorage) to restore before the spec
|
|
193
194
|
* runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
|
|
194
195
|
*/
|
|
195
|
-
const SessionNameSchema = slug("session name");
|
|
196
|
+
const SessionNameSchema = slug$1("session name");
|
|
196
197
|
/**
|
|
197
198
|
* Sessions to restore before a `mode: live` spec runs: one name or a list,
|
|
198
199
|
* always read back as a list. Multiple names are merged (their cookies +
|
|
@@ -206,7 +207,7 @@ const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema
|
|
|
206
207
|
* registered target is the registry's responsibility, so new targets don't
|
|
207
208
|
* require a schema change.
|
|
208
209
|
*/
|
|
209
|
-
const TargetIdSchema = slug("target");
|
|
210
|
+
const TargetIdSchema = slug$1("target");
|
|
210
211
|
/** The built-in recorder-backed target. `mode:` / `session:` only apply to it. */
|
|
211
212
|
const AGENT_BROWSER_TARGET = "agent-browser";
|
|
212
213
|
/**
|
|
@@ -3454,6 +3455,48 @@ const ReportArtifactSchema = z.object({
|
|
|
3454
3455
|
sizeBytes: z.number()
|
|
3455
3456
|
});
|
|
3456
3457
|
/**
|
|
3458
|
+
* Everything one spec's measurement could not place.
|
|
3459
|
+
*
|
|
3460
|
+
* These are load-bearing, not diagnostics. An execution nobody could attribute
|
|
3461
|
+
* is indistinguishable from one that never happened, and "never happened" is
|
|
3462
|
+
* the answer this measurement exists to produce — so each way of losing one is
|
|
3463
|
+
* counted separately and shown next to the result (ADR-0021).
|
|
3464
|
+
*/
|
|
3465
|
+
const CoverageGapsSchema = z.object({
|
|
3466
|
+
unattributed: z.number(),
|
|
3467
|
+
unmappedScripts: z.number(),
|
|
3468
|
+
unmappedRanges: z.number(),
|
|
3469
|
+
outsideProject: z.number(),
|
|
3470
|
+
unresolvedSources: z.number(),
|
|
3471
|
+
uninstrumentedFiles: z.number(),
|
|
3472
|
+
uninstrumentedProcesses: z.number(),
|
|
3473
|
+
droppedPushes: z.number(),
|
|
3474
|
+
unmappedActorEvents: z.number().default(0),
|
|
3475
|
+
outsideWindowEvents: z.number().default(0)
|
|
3476
|
+
});
|
|
3477
|
+
/**
|
|
3478
|
+
* What one spec's execution actually reached: V8's own counters for the
|
|
3479
|
+
* browser and per-request instrumentation for the server, unioned on the spec
|
|
3480
|
+
* id both sides carry (ADR-0021).
|
|
3481
|
+
*
|
|
3482
|
+
* `backendReported` / `frontendReported` separate "reached nothing" from "that
|
|
3483
|
+
* half never answered", which otherwise render identically as zero.
|
|
3484
|
+
*/
|
|
3485
|
+
const ReportCoverageSchema = z.object({
|
|
3486
|
+
files: z.array(z.string()),
|
|
3487
|
+
frontendFiles: z.number(),
|
|
3488
|
+
backendFiles: z.number(),
|
|
3489
|
+
backendReported: z.boolean(),
|
|
3490
|
+
frontendReported: z.boolean(),
|
|
3491
|
+
frontendStopped: z.boolean(),
|
|
3492
|
+
actorWindows: z.array(z.object({
|
|
3493
|
+
key: z.string(),
|
|
3494
|
+
events: z.number()
|
|
3495
|
+
})).default([]),
|
|
3496
|
+
excludedDependencies: z.number(),
|
|
3497
|
+
gaps: CoverageGapsSchema
|
|
3498
|
+
});
|
|
3499
|
+
/**
|
|
3457
3500
|
* Per-step / per-run cost+usage record, pulled from the SDK's `result` message.
|
|
3458
3501
|
* Every numeric field is nullable so the report can carry partial telemetry
|
|
3459
3502
|
* (e.g. when the SDK omits a field, or when a step was skipped).
|
|
@@ -3552,6 +3595,8 @@ const ReportSpecResultSchema = z.object({
|
|
|
3552
3595
|
evidence: z.array(ReportEvidenceSchema).nullable(),
|
|
3553
3596
|
evidenceUnavailable: z.string().optional(),
|
|
3554
3597
|
artifacts: z.array(ReportArtifactSchema).optional(),
|
|
3598
|
+
coverage: ReportCoverageSchema.optional(),
|
|
3599
|
+
coverageUnavailable: z.string().optional(),
|
|
3555
3600
|
liveRun: LiveReportRunSchema.nullable()
|
|
3556
3601
|
});
|
|
3557
3602
|
/**
|
|
@@ -3575,6 +3620,17 @@ const GitEnvelopeSchema = z.object({
|
|
|
3575
3620
|
baseSha: z.string().nullable().optional(),
|
|
3576
3621
|
baseSource: BaseSourceSchema.nullable().optional()
|
|
3577
3622
|
});
|
|
3623
|
+
/**
|
|
3624
|
+
* The denominator for the run's coverage measurement: every source file under
|
|
3625
|
+
* `coverage.include`, enumerated by the run from the same checkout the specs
|
|
3626
|
+
* ran against — which is what entitles a viewer to read "absent from every
|
|
3627
|
+
* row's file set" as "uncovered" rather than "unknown". Run-level because it
|
|
3628
|
+
* is a property of the checkout, not of any one spec.
|
|
3629
|
+
*/
|
|
3630
|
+
const CoverageUniverseSchema = z.object({
|
|
3631
|
+
include: z.array(z.string()),
|
|
3632
|
+
files: z.array(z.string())
|
|
3633
|
+
});
|
|
3578
3634
|
const RunReportDataSchema = z.object({
|
|
3579
3635
|
schemaVersion: z.literal(1),
|
|
3580
3636
|
kind: ReportKindSchema.default("run"),
|
|
@@ -3589,6 +3645,7 @@ const RunReportDataSchema = z.object({
|
|
|
3589
3645
|
triageUserPromptHash: z.string().optional(),
|
|
3590
3646
|
deployedSha: z.string().optional(),
|
|
3591
3647
|
cost: ReportCostSchema.nullable().default(null),
|
|
3648
|
+
coverageUniverse: CoverageUniverseSchema.optional(),
|
|
3592
3649
|
results: z.array(ReportSpecResultSchema)
|
|
3593
3650
|
});
|
|
3594
3651
|
/** Shape of the "export labels" download produced by the report's client-side JS. */
|
|
@@ -5428,46 +5485,1670 @@ function computeLineDiff(a, b) {
|
|
|
5428
5485
|
});
|
|
5429
5486
|
j++;
|
|
5430
5487
|
}
|
|
5431
|
-
while (i < n) out.push({
|
|
5432
|
-
kind: "del",
|
|
5433
|
-
text: a[i++]
|
|
5434
|
-
});
|
|
5435
|
-
while (j < m) out.push({
|
|
5436
|
-
kind: "add",
|
|
5437
|
-
text: b[j++]
|
|
5438
|
-
});
|
|
5439
|
-
return out.map((l) => l.kind === "add" ? `+ ${l.text}` : l.kind === "del" ? `- ${l.text}` : ` ${l.text}`);
|
|
5488
|
+
while (i < n) out.push({
|
|
5489
|
+
kind: "del",
|
|
5490
|
+
text: a[i++]
|
|
5491
|
+
});
|
|
5492
|
+
while (j < m) out.push({
|
|
5493
|
+
kind: "add",
|
|
5494
|
+
text: b[j++]
|
|
5495
|
+
});
|
|
5496
|
+
return out.map((l) => l.kind === "add" ? `+ ${l.text}` : l.kind === "del" ? `- ${l.text}` : ` ${l.text}`);
|
|
5497
|
+
}
|
|
5498
|
+
function truncate$1(s, n) {
|
|
5499
|
+
if (s.length <= n) return s;
|
|
5500
|
+
return s.slice(s.length - n);
|
|
5501
|
+
}
|
|
5502
|
+
//#endregion
|
|
5503
|
+
//#region src/run/output-tail.ts
|
|
5504
|
+
/** Cap on the per-spec output tail kept for the report / analysis prompt. */
|
|
5505
|
+
const OUTPUT_TAIL_CAP = 64 * 1024;
|
|
5506
|
+
/**
|
|
5507
|
+
* Keeps the LAST `cap` characters appended — test runners put the failure
|
|
5508
|
+
* summary at the end of their output, so the tail is what's worth keeping on
|
|
5509
|
+
* overflow. Dependency-free so both the vitest pipeline and the runCommand
|
|
5510
|
+
* runner (src/targets/run-command-runner.ts) can use it without importing the
|
|
5511
|
+
* whole pipeline.
|
|
5512
|
+
*/
|
|
5513
|
+
var TailBuffer = class {
|
|
5514
|
+
buf = "";
|
|
5515
|
+
cap;
|
|
5516
|
+
constructor(cap) {
|
|
5517
|
+
this.cap = cap;
|
|
5518
|
+
}
|
|
5519
|
+
append(s) {
|
|
5520
|
+
this.buf += s;
|
|
5521
|
+
if (this.buf.length > this.cap * 2) this.buf = this.buf.slice(-this.cap);
|
|
5522
|
+
}
|
|
5523
|
+
toString() {
|
|
5524
|
+
if (this.buf.length <= this.cap) return this.buf;
|
|
5525
|
+
return `[...output truncated...]\n${this.buf.slice(-this.cap)}`;
|
|
5526
|
+
}
|
|
5527
|
+
};
|
|
5528
|
+
//#endregion
|
|
5529
|
+
//#region src/coverage/actors.ts
|
|
5530
|
+
/**
|
|
5531
|
+
* Attribution by who acted, for the requests that cannot carry a spec id.
|
|
5532
|
+
*
|
|
5533
|
+
* The measurement's normal carrier is the request itself — a cookie the browser
|
|
5534
|
+
* holds, a baggage header, a Temporal header. A webhook from a chat platform
|
|
5535
|
+
* has none of them: the browser only ever talked to the platform, and what
|
|
5536
|
+
* reaches the application was sent by the platform's servers. Everything such a
|
|
5537
|
+
* flow runs would be unattributed, which for a suite whose majority is chat
|
|
5538
|
+
* flows means the measurement misses its main subject.
|
|
5539
|
+
*
|
|
5540
|
+
* What the webhook does carry is who caused it. If exactly one spec may act as
|
|
5541
|
+
* that identity at a time, "who" plus "when" identifies the spec — so the
|
|
5542
|
+
* application records only the fact (`this identity acted, at this instant`)
|
|
5543
|
+
* and every judgement about which spec that belongs to is made here and in the
|
|
5544
|
+
* sink. Nothing flows the other way: the application is never told which
|
|
5545
|
+
* identities matter or which windows are open, so there is no table to
|
|
5546
|
+
* distribute, go stale, or leak one project's identities into another's logs.
|
|
5547
|
+
*/
|
|
5548
|
+
/**
|
|
5549
|
+
* Quiet gap enforced between two specs that act as the same identity.
|
|
5550
|
+
*
|
|
5551
|
+
* The application stamps events with its own clock and the sink judges them
|
|
5552
|
+
* against its own, so an event near a boundary could fall on either side. Three
|
|
5553
|
+
* seconds is over two push intervals, which also means the window being closed
|
|
5554
|
+
* has had time to receive everything still in flight for it.
|
|
5555
|
+
*/
|
|
5556
|
+
const ACTOR_DRAIN_MS = 3e3;
|
|
5557
|
+
ACTOR_DRAIN_MS / 2;
|
|
5558
|
+
const NO_ACTORS = {
|
|
5559
|
+
windows: [],
|
|
5560
|
+
tagToKey: /* @__PURE__ */ new Map(),
|
|
5561
|
+
windowsForSpec: /* @__PURE__ */ new Map()
|
|
5562
|
+
};
|
|
5563
|
+
/**
|
|
5564
|
+
* Reads the config's actors into the plan the run plays out, refusing anything
|
|
5565
|
+
* ambiguous rather than measuring under a guess.
|
|
5566
|
+
*
|
|
5567
|
+
* Every rejection here is one that would otherwise surface as "this spec
|
|
5568
|
+
* reached nothing": an identity that resolved to nothing matches no event, and
|
|
5569
|
+
* two entries resolving alike make each other's events unattributable.
|
|
5570
|
+
*/
|
|
5571
|
+
async function resolveActors(actors, cwd) {
|
|
5572
|
+
const providers = Object.keys(actors);
|
|
5573
|
+
if (providers.length === 0) return NO_ACTORS;
|
|
5574
|
+
const known = new Set((await listAllSpecsWithSpecFile(cwd)).map(specKey));
|
|
5575
|
+
const windows = [];
|
|
5576
|
+
const tagToKey = /* @__PURE__ */ new Map();
|
|
5577
|
+
const windowsForSpec = /* @__PURE__ */ new Map();
|
|
5578
|
+
for (const provider of providers) for (const [identity, specs] of Object.entries(actors[provider] ?? {})) {
|
|
5579
|
+
const key = `${provider}:${identity}`;
|
|
5580
|
+
const refs = [...iterEnvRefNames(identity)];
|
|
5581
|
+
if (refs.length === 0) throw new RunUsageError(`coverage.actors.${key} names an identity directly — write it as a variable (e.g. \${TEST_USER_ID}) so the value stays out of reports and the hub`);
|
|
5582
|
+
const missing = refs.filter((name) => process.env[name] === void 0 || process.env[name] === "");
|
|
5583
|
+
if (missing.length > 0) throw new RunUsageError(`coverage.actors.${key} needs ${missing.join(", ")}, which ${missing.length === 1 ? "is" : "are"} not set — an identity that resolves to nothing matches no event, and the specs under it would report reaching nothing`);
|
|
5584
|
+
const tag = `${provider}:${resolveEnvRefs(identity)}`;
|
|
5585
|
+
const clash = tagToKey.get(tag);
|
|
5586
|
+
if (clash !== void 0) throw new RunUsageError(`coverage.actors.${key} and coverage.actors.${clash} name the same identity — their windows would overlap and neither one's events could be told apart`);
|
|
5587
|
+
for (const member of specs) if (!known.has(member)) throw new RunUsageError(`coverage.actors.${key} lists "${member}", which is not a spec in this project`);
|
|
5588
|
+
const window = {
|
|
5589
|
+
key,
|
|
5590
|
+
tag,
|
|
5591
|
+
specs
|
|
5592
|
+
};
|
|
5593
|
+
windows.push(window);
|
|
5594
|
+
tagToKey.set(tag, key);
|
|
5595
|
+
for (const member of specs) {
|
|
5596
|
+
const owned = windowsForSpec.get(member);
|
|
5597
|
+
if (owned) owned.push(window);
|
|
5598
|
+
else windowsForSpec.set(member, [window]);
|
|
5599
|
+
}
|
|
5600
|
+
}
|
|
5601
|
+
return {
|
|
5602
|
+
windows,
|
|
5603
|
+
tagToKey,
|
|
5604
|
+
windowsForSpec
|
|
5605
|
+
};
|
|
5606
|
+
}
|
|
5607
|
+
/**
|
|
5608
|
+
* The serial groups the plan implies: two specs acting as one identity cannot
|
|
5609
|
+
* overlap, or neither could claim what happened while both were running.
|
|
5610
|
+
*/
|
|
5611
|
+
function actorGroups(plan) {
|
|
5612
|
+
if (plan.windowsForSpec.size === 0) return () => [];
|
|
5613
|
+
return (ref) => (plan.windowsForSpec.get(specKey(ref)) ?? []).map((window) => window.key);
|
|
5614
|
+
}
|
|
5615
|
+
//#endregion
|
|
5616
|
+
//#region src/coverage/contract.ts
|
|
5617
|
+
/**
|
|
5618
|
+
* The names and shapes ccqa agrees on with the instrumented application.
|
|
5619
|
+
*
|
|
5620
|
+
* Restated from `ccqa-tools`'s `wire.ts` rather than imported: the CLI must
|
|
5621
|
+
* not depend on the instrumentation SDK, which is installed in the application
|
|
5622
|
+
* under test and versioned separately. `contract.test.ts` asserts the two still
|
|
5623
|
+
* agree — a drift here reports "the spec reached no server code", which is
|
|
5624
|
+
* indistinguishable from the truth it is supposed to measure.
|
|
5625
|
+
*
|
|
5626
|
+
* Not named `wire.ts` like its counterpart, deliberately. The one moment anyone
|
|
5627
|
+
* opens both is while working out why the sink saw nothing, and two tabs with
|
|
5628
|
+
* one name is the wrong thing to hand them.
|
|
5629
|
+
*/
|
|
5630
|
+
/** Set on the browser by the acquisition engine, scoped to the instrumented origins. */
|
|
5631
|
+
const COVERAGE_COOKIE = "__ccqa_coverage";
|
|
5632
|
+
/** What the browser engine leaves for the run to read back at collect time. */
|
|
5633
|
+
const FRONTEND_COVERAGE_FILE = "coverage-frontend.json";
|
|
5634
|
+
/** The only spec-id shape this run issues, and the only one the sink accepts. */
|
|
5635
|
+
const SPEC_ID_PATTERN = /^[A-Za-z0-9._\-/]{1,200}$/;
|
|
5636
|
+
//#endregion
|
|
5637
|
+
//#region src/coverage/sink.ts
|
|
5638
|
+
/**
|
|
5639
|
+
* Where instrumented application processes push what they reached. Why they
|
|
5640
|
+
* push rather than being scraped is ADR-0021.
|
|
5641
|
+
*
|
|
5642
|
+
* It authenticates nothing. The gate is the set of spec ids this run issued —
|
|
5643
|
+
* a token would have to be configured on both sides to add anything, and the
|
|
5644
|
+
* sink binds to loopback by default.
|
|
5645
|
+
*/
|
|
5646
|
+
/** What an instrumented process pushes, once a second. */
|
|
5647
|
+
const PushSchema = z.object({
|
|
5648
|
+
protocol: z.literal(1),
|
|
5649
|
+
pid: z.number(),
|
|
5650
|
+
startedAt: z.number(),
|
|
5651
|
+
unattributed: z.number(),
|
|
5652
|
+
specs: z.record(z.string(), z.array(z.string())),
|
|
5653
|
+
boot: z.array(z.string()),
|
|
5654
|
+
uninstrumentedFiles: z.number().default(0),
|
|
5655
|
+
uninstrumentedProcess: z.boolean().default(false),
|
|
5656
|
+
droppedPushes: z.number().default(0),
|
|
5657
|
+
actors: z.array(z.object({
|
|
5658
|
+
tag: z.string(),
|
|
5659
|
+
at: z.number(),
|
|
5660
|
+
files: z.array(z.string())
|
|
5661
|
+
})).default([])
|
|
5662
|
+
});
|
|
5663
|
+
const MAX_BODY_BYTES$6 = 8 * 1024 * 1024;
|
|
5664
|
+
var CoverageSink = class CoverageSink {
|
|
5665
|
+
/** Where instrumented processes push to. Known once the socket is bound. */
|
|
5666
|
+
url = "";
|
|
5667
|
+
specs = /* @__PURE__ */ new Map();
|
|
5668
|
+
bootFiles = /* @__PURE__ */ new Set();
|
|
5669
|
+
/** What each reporting process last said about itself, keyed so a restart is a new one. */
|
|
5670
|
+
processes = /* @__PURE__ */ new Map();
|
|
5671
|
+
pushesReceived = 0;
|
|
5672
|
+
rejected = 0;
|
|
5673
|
+
malformed = 0;
|
|
5674
|
+
/** Every turn this run has handed out, oldest first. Kept for the whole run. */
|
|
5675
|
+
windows = [];
|
|
5676
|
+
/** Per spec, per window, the distinct events that landed in it. Drives the row's count. */
|
|
5677
|
+
matched = /* @__PURE__ */ new Map();
|
|
5678
|
+
/** Events from a declared identity that fell outside every turn it was given. */
|
|
5679
|
+
outsideWindow = /* @__PURE__ */ new Map();
|
|
5680
|
+
/**
|
|
5681
|
+
* Events from identities this project never declared — other people using the
|
|
5682
|
+
* same environment. Deduplicated by timestamp alone: the identity is dropped
|
|
5683
|
+
* on arrival, so there is nothing else left to tell two of them apart.
|
|
5684
|
+
*/
|
|
5685
|
+
unmappedAt = /* @__PURE__ */ new Set();
|
|
5686
|
+
server;
|
|
5687
|
+
/** Spec ids this run issued. A push naming anything else is dropped. */
|
|
5688
|
+
issued;
|
|
5689
|
+
/** Declared identities to their display key. A tag absent here is somebody else's. */
|
|
5690
|
+
tagToKey;
|
|
5691
|
+
constructor(server, issued, tagToKey) {
|
|
5692
|
+
this.server = server;
|
|
5693
|
+
this.issued = issued;
|
|
5694
|
+
this.tagToKey = tagToKey;
|
|
5695
|
+
}
|
|
5696
|
+
/**
|
|
5697
|
+
* Binds and starts accepting pushes. `issued` is fixed at start: the cookie
|
|
5698
|
+
* is client-controlled, so an id this run never issued is refused here
|
|
5699
|
+
* rather than trusted into a report.
|
|
5700
|
+
*/
|
|
5701
|
+
static async start(host, port, issued, tagToKey = /* @__PURE__ */ new Map()) {
|
|
5702
|
+
const sink = new CoverageSink(createServer(), issued, tagToKey);
|
|
5703
|
+
sink.server.on("request", (request, response) => {
|
|
5704
|
+
sink.handle(request, response);
|
|
5705
|
+
});
|
|
5706
|
+
await new Promise((resolve, reject) => {
|
|
5707
|
+
sink.server.once("error", reject);
|
|
5708
|
+
sink.server.listen(port, host, () => {
|
|
5709
|
+
sink.server.removeListener("error", reject);
|
|
5710
|
+
resolve();
|
|
5711
|
+
});
|
|
5712
|
+
});
|
|
5713
|
+
const address = sink.server.address();
|
|
5714
|
+
sink.url = `http://${formatHost(host)}:${address.port}`;
|
|
5715
|
+
return sink;
|
|
5716
|
+
}
|
|
5717
|
+
/** What `specId` reached so far. Reads do not clear: late pushes still land. */
|
|
5718
|
+
filesFor(specId) {
|
|
5719
|
+
return this.specs.get(specId)?.files;
|
|
5720
|
+
}
|
|
5721
|
+
/** Gives `specId` sole claim to `window`'s identity from now until it is closed. */
|
|
5722
|
+
openWindow(window, specId) {
|
|
5723
|
+
this.windows.push({
|
|
5724
|
+
tag: window.tag,
|
|
5725
|
+
key: window.key,
|
|
5726
|
+
specId,
|
|
5727
|
+
openedAt: Date.now(),
|
|
5728
|
+
closedAt: void 0
|
|
5729
|
+
});
|
|
5730
|
+
}
|
|
5731
|
+
/** Ends the open turn on `tag`. Later events from it belong to nobody. */
|
|
5732
|
+
closeWindow(tag) {
|
|
5733
|
+
for (let i = this.windows.length - 1; i >= 0; i--) {
|
|
5734
|
+
const window = this.windows[i];
|
|
5735
|
+
if (window.tag !== tag || window.closedAt !== void 0) continue;
|
|
5736
|
+
window.closedAt = Date.now();
|
|
5737
|
+
return;
|
|
5738
|
+
}
|
|
5739
|
+
}
|
|
5740
|
+
/** When the run may next open a turn on `tag`, given the drain it has to leave. */
|
|
5741
|
+
lastClosedAt(tag) {
|
|
5742
|
+
let latest;
|
|
5743
|
+
for (const window of this.windows) {
|
|
5744
|
+
if (window.tag !== tag || window.closedAt === void 0) continue;
|
|
5745
|
+
latest = window.closedAt;
|
|
5746
|
+
}
|
|
5747
|
+
return latest;
|
|
5748
|
+
}
|
|
5749
|
+
/** Per window key, how many distinct events this spec was credited with. */
|
|
5750
|
+
actorEventsFor(specId) {
|
|
5751
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5752
|
+
for (const [key, events] of this.matched.get(specId) ?? []) counts.set(key, events.size);
|
|
5753
|
+
return counts;
|
|
5754
|
+
}
|
|
5755
|
+
/**
|
|
5756
|
+
* Events from a declared identity that arrived outside its turns.
|
|
5757
|
+
*
|
|
5758
|
+
* Loud rather than silent: it means something other than this run drove that
|
|
5759
|
+
* identity, and whatever it reached is missing from a spec that looks whole.
|
|
5760
|
+
*/
|
|
5761
|
+
outsideWindowEvents() {
|
|
5762
|
+
return this.outsideWindow;
|
|
5763
|
+
}
|
|
5764
|
+
/** Events from identities this project never declared. Their reach belongs to nobody. */
|
|
5765
|
+
unmappedActorEvents() {
|
|
5766
|
+
return this.unmappedAt.size;
|
|
5767
|
+
}
|
|
5768
|
+
/** Executions that ran while `specId` was open but outside its context. */
|
|
5769
|
+
unattributedFor(specId) {
|
|
5770
|
+
const spec = this.specs.get(specId);
|
|
5771
|
+
if (spec === void 0) return 0;
|
|
5772
|
+
let total = 0;
|
|
5773
|
+
for (const [process, baseline] of spec.baseline) total += Math.max(0, (this.processes.get(process)?.unattributed ?? 0) - baseline);
|
|
5774
|
+
return total;
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* Files reached at module top level. Deliberately not folded into any spec:
|
|
5778
|
+
* the first spec to import a module would otherwise own it, which makes a
|
|
5779
|
+
* spec's result depend on the order the run happened to execute.
|
|
5780
|
+
*/
|
|
5781
|
+
boot() {
|
|
5782
|
+
return this.bootFiles;
|
|
5783
|
+
}
|
|
5784
|
+
/** True once any instrumented process has reported — i.e. the server half is wired up. */
|
|
5785
|
+
heardFromApplication() {
|
|
5786
|
+
return this.pushesReceived > 0;
|
|
5787
|
+
}
|
|
5788
|
+
/**
|
|
5789
|
+
* Specs some process attributed a file to.
|
|
5790
|
+
*
|
|
5791
|
+
* Distinct from `heardFromApplication`, which a process satisfies with its
|
|
5792
|
+
* boot set alone. An application that reports but attributes nothing has the
|
|
5793
|
+
* instrumentation working and the spec cookie not arriving — and that reads
|
|
5794
|
+
* identically to a server that genuinely ran no code.
|
|
5795
|
+
*/
|
|
5796
|
+
attributedSpecs() {
|
|
5797
|
+
return this.specs.size;
|
|
5798
|
+
}
|
|
5799
|
+
/** Pushes refused because they named a spec id this run never issued. */
|
|
5800
|
+
rejectedPushes() {
|
|
5801
|
+
return this.rejected;
|
|
5802
|
+
}
|
|
5803
|
+
/**
|
|
5804
|
+
* Pushes the sink could not read. Counted because the failure is otherwise
|
|
5805
|
+
* invisible from this side and shows up as "the spec reached no server code".
|
|
5806
|
+
*/
|
|
5807
|
+
malformedPushes() {
|
|
5808
|
+
return this.malformed;
|
|
5809
|
+
}
|
|
5810
|
+
/**
|
|
5811
|
+
* Files the applications could not instrument — they can never report reach.
|
|
5812
|
+
*
|
|
5813
|
+
* Not baselined, unlike `unattributed` and `droppedPushes`: a file that
|
|
5814
|
+
* failed to rewrite when the process booted is still unrewritten now, so it
|
|
5815
|
+
* is a standing condition of this run and not a past event.
|
|
5816
|
+
*/
|
|
5817
|
+
uninstrumentedFiles() {
|
|
5818
|
+
let total = 0;
|
|
5819
|
+
for (const report of this.processes.values()) total += report.uninstrumentedFiles;
|
|
5820
|
+
return total;
|
|
5821
|
+
}
|
|
5822
|
+
/**
|
|
5823
|
+
* Application processes that instrumented nothing at all. Kept apart from
|
|
5824
|
+
* the file count because one of these hides every file the process ran, and
|
|
5825
|
+
* folded together it would read as a single missing file.
|
|
5826
|
+
*/
|
|
5827
|
+
uninstrumentedProcesses() {
|
|
5828
|
+
let total = 0;
|
|
5829
|
+
for (const report of this.processes.values()) if (report.blind) total++;
|
|
5830
|
+
return total;
|
|
5831
|
+
}
|
|
5832
|
+
/** Pushes the applications could not deliver during this run. Never seen here. */
|
|
5833
|
+
droppedPushes() {
|
|
5834
|
+
let total = 0;
|
|
5835
|
+
for (const report of this.processes.values()) total += Math.max(0, report.droppedLatest - report.droppedBaseline);
|
|
5836
|
+
return total;
|
|
5837
|
+
}
|
|
5838
|
+
async close() {
|
|
5839
|
+
await new Promise((resolve) => {
|
|
5840
|
+
this.server.close(() => {
|
|
5841
|
+
resolve();
|
|
5842
|
+
});
|
|
5843
|
+
});
|
|
5844
|
+
}
|
|
5845
|
+
async handle(request, response) {
|
|
5846
|
+
if (request.method !== "POST") {
|
|
5847
|
+
response.writeHead(405).end();
|
|
5848
|
+
return;
|
|
5849
|
+
}
|
|
5850
|
+
let body;
|
|
5851
|
+
try {
|
|
5852
|
+
body = await readBody$1(request);
|
|
5853
|
+
} catch {
|
|
5854
|
+
this.malformed++;
|
|
5855
|
+
response.writeHead(413).end();
|
|
5856
|
+
return;
|
|
5857
|
+
}
|
|
5858
|
+
let push;
|
|
5859
|
+
try {
|
|
5860
|
+
push = PushSchema.parse(JSON.parse(body));
|
|
5861
|
+
} catch {
|
|
5862
|
+
this.malformed++;
|
|
5863
|
+
response.writeHead(400).end();
|
|
5864
|
+
return;
|
|
5865
|
+
}
|
|
5866
|
+
this.accept(push);
|
|
5867
|
+
response.writeHead(204).end();
|
|
5868
|
+
}
|
|
5869
|
+
accept(push) {
|
|
5870
|
+
const process = `${push.pid}:${push.startedAt}`;
|
|
5871
|
+
const known = this.processes.get(process);
|
|
5872
|
+
const previous = known?.unattributed ?? push.unattributed;
|
|
5873
|
+
for (const file of push.boot) this.bootFiles.add(file);
|
|
5874
|
+
for (const [specId, files] of Object.entries(push.specs)) {
|
|
5875
|
+
if (!SPEC_ID_PATTERN.test(specId) || !this.issued.has(specId)) {
|
|
5876
|
+
this.rejected++;
|
|
5877
|
+
continue;
|
|
5878
|
+
}
|
|
5879
|
+
let spec = this.specs.get(specId);
|
|
5880
|
+
if (spec === void 0) {
|
|
5881
|
+
spec = {
|
|
5882
|
+
files: /* @__PURE__ */ new Set(),
|
|
5883
|
+
baseline: /* @__PURE__ */ new Map()
|
|
5884
|
+
};
|
|
5885
|
+
this.specs.set(specId, spec);
|
|
5886
|
+
}
|
|
5887
|
+
if (!spec.baseline.has(process)) spec.baseline.set(process, previous);
|
|
5888
|
+
for (const file of files) spec.files.add(file);
|
|
5889
|
+
}
|
|
5890
|
+
for (const event of push.actors) this.attributeActorEvent(event, process, previous);
|
|
5891
|
+
this.processes.set(process, {
|
|
5892
|
+
unattributed: push.unattributed,
|
|
5893
|
+
uninstrumentedFiles: push.uninstrumentedFiles,
|
|
5894
|
+
blind: known?.blind === true || push.uninstrumentedProcess,
|
|
5895
|
+
droppedBaseline: known?.droppedBaseline ?? push.droppedPushes,
|
|
5896
|
+
droppedLatest: push.droppedPushes
|
|
5897
|
+
});
|
|
5898
|
+
this.pushesReceived++;
|
|
5899
|
+
}
|
|
5900
|
+
/**
|
|
5901
|
+
* Decides which spec, if any, an identity's work belongs to.
|
|
5902
|
+
*
|
|
5903
|
+
* `at` is when the work was first asked for, not when it ran — an activity a
|
|
5904
|
+
* queue picks up minutes later still carries the instant that caused it, so a
|
|
5905
|
+
* slow tail lands in the turn that started it rather than the one running now.
|
|
5906
|
+
*/
|
|
5907
|
+
attributeActorEvent(event, process, previous) {
|
|
5908
|
+
const key = this.tagToKey.get(event.tag);
|
|
5909
|
+
if (key === void 0) {
|
|
5910
|
+
this.unmappedAt.add(event.at);
|
|
5911
|
+
return;
|
|
5912
|
+
}
|
|
5913
|
+
const window = this.windowAt(event.tag, event.at);
|
|
5914
|
+
if (window === void 0) {
|
|
5915
|
+
this.outsideWindow.set(key, (this.outsideWindow.get(key) ?? 0) + 1);
|
|
5916
|
+
return;
|
|
5917
|
+
}
|
|
5918
|
+
const spec = this.specs.get(window.specId) ?? {
|
|
5919
|
+
files: /* @__PURE__ */ new Set(),
|
|
5920
|
+
baseline: /* @__PURE__ */ new Map()
|
|
5921
|
+
};
|
|
5922
|
+
this.specs.set(window.specId, spec);
|
|
5923
|
+
if (!spec.baseline.has(process)) spec.baseline.set(process, previous);
|
|
5924
|
+
for (const file of event.files) spec.files.add(file);
|
|
5925
|
+
let byKey = this.matched.get(window.specId);
|
|
5926
|
+
if (byKey === void 0) {
|
|
5927
|
+
byKey = /* @__PURE__ */ new Map();
|
|
5928
|
+
this.matched.set(window.specId, byKey);
|
|
5929
|
+
}
|
|
5930
|
+
const events = byKey.get(key) ?? /* @__PURE__ */ new Set();
|
|
5931
|
+
events.add(`${event.tag} ${event.at}`);
|
|
5932
|
+
byKey.set(key, events);
|
|
5933
|
+
}
|
|
5934
|
+
/**
|
|
5935
|
+
* The turn on `tag` that `at` falls in, latest first.
|
|
5936
|
+
*
|
|
5937
|
+
* Both clocks are involved — the application stamped `at`, this process
|
|
5938
|
+
* stamped the bounds — so each bound gives a little. It cannot reach the
|
|
5939
|
+
* neighbouring turn: the run leaves a full drain between two turns on one
|
|
5940
|
+
* identity and this reaches half of it.
|
|
5941
|
+
*/
|
|
5942
|
+
windowAt(tag, at) {
|
|
5943
|
+
let found;
|
|
5944
|
+
for (const window of this.windows) {
|
|
5945
|
+
if (window.tag !== tag) continue;
|
|
5946
|
+
if (at < window.openedAt - 1500) continue;
|
|
5947
|
+
if (window.closedAt !== void 0 && at > window.closedAt + 1500) continue;
|
|
5948
|
+
found = window;
|
|
5949
|
+
}
|
|
5950
|
+
return found;
|
|
5951
|
+
}
|
|
5952
|
+
};
|
|
5953
|
+
function formatHost(host) {
|
|
5954
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
5955
|
+
}
|
|
5956
|
+
async function readBody$1(request) {
|
|
5957
|
+
const chunks = [];
|
|
5958
|
+
let size = 0;
|
|
5959
|
+
for await (const chunk of request) {
|
|
5960
|
+
const buffer = chunk;
|
|
5961
|
+
size += buffer.length;
|
|
5962
|
+
if (size > MAX_BODY_BYTES$6) throw new Error("coverage push too large");
|
|
5963
|
+
chunks.push(buffer);
|
|
5964
|
+
}
|
|
5965
|
+
return (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString("utf8");
|
|
5966
|
+
}
|
|
5967
|
+
//#endregion
|
|
5968
|
+
//#region src/coverage/frontend/source-map.ts
|
|
5969
|
+
/** Parses a source map JSON string. Returns undefined for anything unusable. */
|
|
5970
|
+
function parseSourceMap(json) {
|
|
5971
|
+
let parsed;
|
|
5972
|
+
try {
|
|
5973
|
+
parsed = JSON.parse(json);
|
|
5974
|
+
} catch {
|
|
5975
|
+
return;
|
|
5976
|
+
}
|
|
5977
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
5978
|
+
const candidate = parsed;
|
|
5979
|
+
if (candidate.sections !== void 0) return void 0;
|
|
5980
|
+
if (candidate.version !== 3) return void 0;
|
|
5981
|
+
if (typeof candidate.mappings !== "string") return void 0;
|
|
5982
|
+
if (!Array.isArray(candidate.sources)) return void 0;
|
|
5983
|
+
return candidate;
|
|
5984
|
+
}
|
|
5985
|
+
const SOURCE_MAPPING_URL_RE = /\/\/[#@][ \t]*sourceMappingURL=([^\s]+)/g;
|
|
5986
|
+
/** Extracts the `//# sourceMappingURL=` value from generated code, if present. */
|
|
5987
|
+
function readSourceMappingUrl(code) {
|
|
5988
|
+
let last;
|
|
5989
|
+
for (const match of code.matchAll(SOURCE_MAPPING_URL_RE)) last = match[1];
|
|
5990
|
+
return last;
|
|
5991
|
+
}
|
|
5992
|
+
const DATA_URL_RE = /^data:([^,]*),(.*)$/s;
|
|
5993
|
+
/** Decodes a `data:` source map URL into its JSON text. Returns undefined otherwise. */
|
|
5994
|
+
function decodeInlineSourceMap(url) {
|
|
5995
|
+
const match = DATA_URL_RE.exec(url);
|
|
5996
|
+
if (!match) return void 0;
|
|
5997
|
+
const [, meta, payload] = match;
|
|
5998
|
+
if (meta === void 0 || payload === void 0 || !/^application\/json/.test(meta)) return void 0;
|
|
5999
|
+
try {
|
|
6000
|
+
return /;base64$/.test(meta) ? Buffer.from(payload, "base64").toString("utf-8") : decodeURIComponent(payload);
|
|
6001
|
+
} catch {
|
|
6002
|
+
return;
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
function joinSourceRoot(root, source) {
|
|
6006
|
+
if (typeof root !== "string" || root === "") return source;
|
|
6007
|
+
return root.endsWith("/") ? `${root}${source}` : `${root}/${source}`;
|
|
6008
|
+
}
|
|
6009
|
+
const VLQ_CONTINUATION_BIT = 32;
|
|
6010
|
+
const VLQ_VALUE_MASK = 31;
|
|
6011
|
+
const VLQ_SHIFT = 5;
|
|
6012
|
+
const BASE64_INDEX = new Map(Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/").map((ch, i) => [ch, i]));
|
|
6013
|
+
/**
|
|
6014
|
+
* Decodes one VLQ value starting at `pos`. Uses multiplication rather than
|
|
6015
|
+
* bit shifts so values beyond 32 bits (large generated files) decode
|
|
6016
|
+
* correctly — `<<` in JS truncates to a signed 32-bit int.
|
|
6017
|
+
*/
|
|
6018
|
+
function decodeVlq(mappings, pos) {
|
|
6019
|
+
let result = 0;
|
|
6020
|
+
let shift = 0;
|
|
6021
|
+
let i = pos;
|
|
6022
|
+
for (;;) {
|
|
6023
|
+
const char = mappings[i];
|
|
6024
|
+
if (char === void 0) throw new Error("truncated VLQ in mappings");
|
|
6025
|
+
const digit = BASE64_INDEX.get(char);
|
|
6026
|
+
if (digit === void 0) throw new Error(`invalid base64 digit in mappings: ${char}`);
|
|
6027
|
+
i += 1;
|
|
6028
|
+
result += (digit & VLQ_VALUE_MASK) * 2 ** shift;
|
|
6029
|
+
if ((digit & VLQ_CONTINUATION_BIT) === 0) break;
|
|
6030
|
+
shift += VLQ_SHIFT;
|
|
6031
|
+
}
|
|
6032
|
+
const negative = result % 2 === 1;
|
|
6033
|
+
const magnitude = Math.floor(result / 2);
|
|
6034
|
+
return {
|
|
6035
|
+
value: negative ? -magnitude : magnitude,
|
|
6036
|
+
next: i
|
|
6037
|
+
};
|
|
6038
|
+
}
|
|
6039
|
+
function isSegmentBoundary(mappings, pos) {
|
|
6040
|
+
return pos >= mappings.length || mappings[pos] === "," || mappings[pos] === ";";
|
|
6041
|
+
}
|
|
6042
|
+
/**
|
|
6043
|
+
* Decodes `mappings` into segments that carry source info (the 4- and
|
|
6044
|
+
* 5-field kind). 1-field segments (generated code with no original source)
|
|
6045
|
+
* are skipped: they can never contribute a source, so keeping them around
|
|
6046
|
+
* would only cost time later.
|
|
6047
|
+
*/
|
|
6048
|
+
function decodeMappings(mappings) {
|
|
6049
|
+
const segments = [];
|
|
6050
|
+
const len = mappings.length;
|
|
6051
|
+
let pos = 0;
|
|
6052
|
+
let generatedLine = 0;
|
|
6053
|
+
let generatedColumn = 0;
|
|
6054
|
+
let sourceIndex = 0;
|
|
6055
|
+
let sourceLine = 0;
|
|
6056
|
+
let sourceColumn = 0;
|
|
6057
|
+
while (pos < len) {
|
|
6058
|
+
const char = mappings[pos];
|
|
6059
|
+
if (char === ";") {
|
|
6060
|
+
generatedLine += 1;
|
|
6061
|
+
generatedColumn = 0;
|
|
6062
|
+
pos += 1;
|
|
6063
|
+
continue;
|
|
6064
|
+
}
|
|
6065
|
+
if (char === ",") {
|
|
6066
|
+
pos += 1;
|
|
6067
|
+
continue;
|
|
6068
|
+
}
|
|
6069
|
+
const col = decodeVlq(mappings, pos);
|
|
6070
|
+
generatedColumn += col.value;
|
|
6071
|
+
pos = col.next;
|
|
6072
|
+
if (isSegmentBoundary(mappings, pos)) continue;
|
|
6073
|
+
const srcIndex = decodeVlq(mappings, pos);
|
|
6074
|
+
sourceIndex += srcIndex.value;
|
|
6075
|
+
pos = srcIndex.next;
|
|
6076
|
+
const srcLine = decodeVlq(mappings, pos);
|
|
6077
|
+
sourceLine += srcLine.value;
|
|
6078
|
+
pos = srcLine.next;
|
|
6079
|
+
const srcColumn = decodeVlq(mappings, pos);
|
|
6080
|
+
sourceColumn += srcColumn.value;
|
|
6081
|
+
pos = srcColumn.next;
|
|
6082
|
+
segments.push({
|
|
6083
|
+
generatedLine,
|
|
6084
|
+
generatedColumn,
|
|
6085
|
+
sourceIndex
|
|
6086
|
+
});
|
|
6087
|
+
if (!isSegmentBoundary(mappings, pos)) pos = decodeVlq(mappings, pos).next;
|
|
6088
|
+
}
|
|
6089
|
+
return segments;
|
|
6090
|
+
}
|
|
6091
|
+
function prepareSourceMap(map, generatedCode) {
|
|
6092
|
+
const lineStarts = computeLineStarts(generatedCode);
|
|
6093
|
+
const positions = [];
|
|
6094
|
+
let segments;
|
|
6095
|
+
try {
|
|
6096
|
+
segments = decodeMappings(map.mappings);
|
|
6097
|
+
} catch {
|
|
6098
|
+
return;
|
|
6099
|
+
}
|
|
6100
|
+
for (const segment of segments) {
|
|
6101
|
+
const lineStart = lineStarts[segment.generatedLine];
|
|
6102
|
+
if (lineStart === void 0) continue;
|
|
6103
|
+
positions.push({
|
|
6104
|
+
offset: lineStart + segment.generatedColumn,
|
|
6105
|
+
sourceIndex: segment.sourceIndex
|
|
6106
|
+
});
|
|
6107
|
+
}
|
|
6108
|
+
positions.sort((a, b) => a.offset - b.offset);
|
|
6109
|
+
return {
|
|
6110
|
+
positions,
|
|
6111
|
+
paths: map.sources.map((source) => source === null ? void 0 : joinSourceRoot(map.sourceRoot, source))
|
|
6112
|
+
};
|
|
6113
|
+
}
|
|
6114
|
+
/** Start offset (UTF-16 code units) of each line in `code`, index 0 for line 0. */
|
|
6115
|
+
function computeLineStarts(code) {
|
|
6116
|
+
const starts = [0];
|
|
6117
|
+
for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
|
|
6118
|
+
return starts;
|
|
6119
|
+
}
|
|
6120
|
+
/** Index of the first mapped position at or after `offset`. */
|
|
6121
|
+
function lowerBound(positions, offset) {
|
|
6122
|
+
let lo = 0;
|
|
6123
|
+
let hi = positions.length;
|
|
6124
|
+
while (lo < hi) {
|
|
6125
|
+
const mid = lo + hi >>> 1;
|
|
6126
|
+
if ((positions[mid]?.offset ?? 0) < offset) lo = mid + 1;
|
|
6127
|
+
else hi = mid;
|
|
6128
|
+
}
|
|
6129
|
+
return lo;
|
|
6130
|
+
}
|
|
6131
|
+
/**
|
|
6132
|
+
* Which of `prepared`'s sources the covered ranges touch.
|
|
6133
|
+
*
|
|
6134
|
+
* Every range is walked even once all sources are known, because a range that
|
|
6135
|
+
* maps nowhere has to be counted — dropping it would turn an unknown into a
|
|
6136
|
+
* silent "never reached".
|
|
6137
|
+
*/
|
|
6138
|
+
function resolveCovered(prepared, ranges) {
|
|
6139
|
+
const seen = new Uint8Array(prepared.paths.length);
|
|
6140
|
+
const sources = [];
|
|
6141
|
+
let unmappedRanges = 0;
|
|
6142
|
+
for (const range of ranges) {
|
|
6143
|
+
let matched = false;
|
|
6144
|
+
for (let i = lowerBound(prepared.positions, range.startOffset); i < prepared.positions.length; i++) {
|
|
6145
|
+
const position = prepared.positions[i];
|
|
6146
|
+
if (position === void 0 || position.offset >= range.endOffset) break;
|
|
6147
|
+
matched = true;
|
|
6148
|
+
if (seen[position.sourceIndex] === 1) continue;
|
|
6149
|
+
seen[position.sourceIndex] = 1;
|
|
6150
|
+
const path = prepared.paths[position.sourceIndex];
|
|
6151
|
+
if (path !== void 0) sources.push(path);
|
|
6152
|
+
}
|
|
6153
|
+
if (!matched) unmappedRanges += 1;
|
|
6154
|
+
}
|
|
6155
|
+
return {
|
|
6156
|
+
sources,
|
|
6157
|
+
unmappedRanges
|
|
6158
|
+
};
|
|
6159
|
+
}
|
|
6160
|
+
//#endregion
|
|
6161
|
+
//#region src/coverage/frontend/source-path.ts
|
|
6162
|
+
/**
|
|
6163
|
+
* Turns the source names a bundler writes into `sources` back into paths a
|
|
6164
|
+
* reader can find in the project.
|
|
6165
|
+
*
|
|
6166
|
+
* Bundlers namespace those entries — `webpack://_N_E/./src/a.ts`,
|
|
6167
|
+
* `webpack-internal:///(pages-dir-browser)/./src/a.ts` — and some write the
|
|
6168
|
+
* absolute path the build machine used. None of the forms is a project path on
|
|
6169
|
+
* its own.
|
|
6170
|
+
*
|
|
6171
|
+
* Anything that still resolves outside the root is dropped rather than
|
|
6172
|
+
* coerced. A framework's own runtime arrives as `../../../node_modules/...`,
|
|
6173
|
+
* and flattening those leading segments would invent a path that exists
|
|
6174
|
+
* nowhere and report the framework as project code nobody has a test for.
|
|
6175
|
+
*
|
|
6176
|
+
* Why a reason and not just `undefined`: dependency code is dropped on purpose
|
|
6177
|
+
* and a name nobody could resolve is a hole in the measurement. Reported as one
|
|
6178
|
+
* number the two are indistinguishable, and since dependencies dominate it by
|
|
6179
|
+
* orders of magnitude, the number reads as noise — which is how a real hole
|
|
6180
|
+
* goes unnoticed inside it.
|
|
6181
|
+
*/
|
|
6182
|
+
/** Dependency code is dropped: an unreached library file is not a missing test. */
|
|
6183
|
+
const VENDOR = /(^|\/)node_modules\//;
|
|
6184
|
+
/** `(rsc)`, `(pages-dir-browser)`, ... — which build layer, not part of the path. */
|
|
6185
|
+
const LAYER = /^\([^)]*\)\//;
|
|
6186
|
+
const DEPENDENCY = { kind: "dependency" };
|
|
6187
|
+
const UNRESOLVED = { kind: "unresolved" };
|
|
6188
|
+
/** `absolute` as a posix path under `root`, or undefined when it is not under it. */
|
|
6189
|
+
function toProjectRelative(root, absolute) {
|
|
6190
|
+
const rel = relative(root, absolute).split(sep).join("/");
|
|
6191
|
+
return rel === "" || rel.startsWith("..") ? void 0 : rel;
|
|
6192
|
+
}
|
|
6193
|
+
function normalizeSourcePath(raw, roots) {
|
|
6194
|
+
if (VENDOR.test(raw)) return DEPENDENCY;
|
|
6195
|
+
let path = raw;
|
|
6196
|
+
const scheme = path.indexOf("://");
|
|
6197
|
+
if (scheme >= 0) {
|
|
6198
|
+
const afterScheme = path.slice(scheme + 3);
|
|
6199
|
+
const slash = afterScheme.indexOf("/");
|
|
6200
|
+
const from = path.startsWith("file:") ? slash : slash + 1;
|
|
6201
|
+
path = slash < 0 ? afterScheme : afterScheme.slice(from);
|
|
6202
|
+
}
|
|
6203
|
+
path = posix.normalize(path.replace(LAYER, ""));
|
|
6204
|
+
if (path === "" || path === ".") return UNRESOLVED;
|
|
6205
|
+
if (path.startsWith("<") || path.startsWith("[")) return UNRESOLVED;
|
|
6206
|
+
const absolute = path.startsWith("/") ? path : resolve(roots.base, path);
|
|
6207
|
+
const rel = toProjectRelative(roots.root, absolute);
|
|
6208
|
+
if (rel === void 0) return UNRESOLVED;
|
|
6209
|
+
return VENDOR.test(rel) ? DEPENDENCY : {
|
|
6210
|
+
kind: "project",
|
|
6211
|
+
path: rel
|
|
6212
|
+
};
|
|
6213
|
+
}
|
|
6214
|
+
//#endregion
|
|
6215
|
+
//#region src/coverage/frontend/build-output.ts
|
|
6216
|
+
/**
|
|
6217
|
+
* Follows a build output back to the source it was compiled from.
|
|
6218
|
+
*
|
|
6219
|
+
* A workspace package is consumed through its published entry, so a bundler
|
|
6220
|
+
* names it `packages/x/dist/index.mjs`. That file exists and is genuinely what
|
|
6221
|
+
* ran, but nobody edits it — reported as-is it becomes an entry in the
|
|
6222
|
+
* untested-file list that no test could ever cover.
|
|
6223
|
+
*
|
|
6224
|
+
* Only a 1:1 build is followed: one output per input, which is what an
|
|
6225
|
+
* unbundled compile produces and what its map states in a single `sources`
|
|
6226
|
+
* entry. A bundle's map lists everything that went into it and cannot say
|
|
6227
|
+
* which of them the file "is", so those are left pointing at the output.
|
|
6228
|
+
*
|
|
6229
|
+
* The server half follows the same 1:1 rule at load time, in `ccqa-tools`'s
|
|
6230
|
+
* `instrument/origin.ts`. It reaches further: it already holds the code, so it
|
|
6231
|
+
* can read an inline map, where this side only reads a sibling `.map`. A
|
|
6232
|
+
* package built with an inline map is therefore reported under its source by
|
|
6233
|
+
* the server and under its build output by the browser.
|
|
6234
|
+
*/
|
|
6235
|
+
/**
|
|
6236
|
+
* The project-relative source `path` was built from, or undefined to keep
|
|
6237
|
+
* `path` as it is.
|
|
6238
|
+
*
|
|
6239
|
+
* Only the sibling `<file>.map` convention is read. Finding an inline map means
|
|
6240
|
+
* reading the whole build output on the chance it carries one, which is a lot
|
|
6241
|
+
* of I/O for a case library builds rarely emit.
|
|
6242
|
+
*/
|
|
6243
|
+
function sourceBehindBuildOutput(path, root) {
|
|
6244
|
+
const output = join(root, path);
|
|
6245
|
+
let json;
|
|
6246
|
+
try {
|
|
6247
|
+
json = readFileSync(`${output}.map`, "utf8");
|
|
6248
|
+
} catch {
|
|
6249
|
+
return;
|
|
6250
|
+
}
|
|
6251
|
+
const map = parseSourceMap(json);
|
|
6252
|
+
if (map === void 0 || map.sources.length !== 1) return void 0;
|
|
6253
|
+
const source = map.sources[0];
|
|
6254
|
+
if (typeof source !== "string" || source === "") return void 0;
|
|
6255
|
+
const absolute = resolve(dirname(output), map.sourceRoot ?? "", source);
|
|
6256
|
+
const rel = toProjectRelative(root, absolute);
|
|
6257
|
+
if (rel === void 0) return void 0;
|
|
6258
|
+
return existsSync(absolute) ? rel : void 0;
|
|
6259
|
+
}
|
|
6260
|
+
//#endregion
|
|
6261
|
+
//#region src/coverage/frontend/resolve.ts
|
|
6262
|
+
/**
|
|
6263
|
+
* What counts as a source file. Shared with the universe enumeration
|
|
6264
|
+
* (universe.ts): the denominator must use the same notion of "source file"
|
|
6265
|
+
* as the reached side, or "uncovered" drifts as one definition evolves.
|
|
6266
|
+
*/
|
|
6267
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
|
6268
|
+
var FrontendResolution = class {
|
|
6269
|
+
specId;
|
|
6270
|
+
coverageDir;
|
|
6271
|
+
roots;
|
|
6272
|
+
fetchText;
|
|
6273
|
+
warn;
|
|
6274
|
+
files = /* @__PURE__ */ new Set();
|
|
6275
|
+
/**
|
|
6276
|
+
* Resolution, memoised. `roots` is fixed for the session and V8 re-reports
|
|
6277
|
+
* every script it has seen on each take, so both answers below are otherwise
|
|
6278
|
+
* recomputed for the whole page at every navigation.
|
|
6279
|
+
*/
|
|
6280
|
+
classified = /* @__PURE__ */ new Map();
|
|
6281
|
+
/** Project path -> the source it was built from, or itself. */
|
|
6282
|
+
sources = /* @__PURE__ */ new Map();
|
|
6283
|
+
/** Decoded once per script and kept; the raw map is dropped with it. */
|
|
6284
|
+
maps = /* @__PURE__ */ new Map();
|
|
6285
|
+
unmappedScripts = 0;
|
|
6286
|
+
unmappedRanges = 0;
|
|
6287
|
+
unresolvedSources = 0;
|
|
6288
|
+
excludedDependencies = 0;
|
|
6289
|
+
/** Set once collection dies: everything after this point was never seen. */
|
|
6290
|
+
stopped = false;
|
|
6291
|
+
/** What the last write held, so an unchanged flush does not rewrite the file. */
|
|
6292
|
+
written = "";
|
|
6293
|
+
dirReady = false;
|
|
6294
|
+
constructor(opts) {
|
|
6295
|
+
this.specId = opts.specId;
|
|
6296
|
+
this.coverageDir = opts.coverageDir;
|
|
6297
|
+
this.roots = opts.roots;
|
|
6298
|
+
this.fetchText = opts.fetchText;
|
|
6299
|
+
this.warn = opts.warn;
|
|
6300
|
+
}
|
|
6301
|
+
async absorb(script) {
|
|
6302
|
+
if (script.ranges.length === 0) return;
|
|
6303
|
+
const direct = this.bundlerModulePath(script.url);
|
|
6304
|
+
if (direct !== void 0) {
|
|
6305
|
+
this.files.add(this.sourceOf(direct));
|
|
6306
|
+
return;
|
|
6307
|
+
}
|
|
6308
|
+
const prepared = await this.loadSourceMap(script);
|
|
6309
|
+
if (prepared === void 0) {
|
|
6310
|
+
this.unmappedScripts++;
|
|
6311
|
+
return;
|
|
6312
|
+
}
|
|
6313
|
+
const resolved = resolveCovered(prepared, [...script.ranges]);
|
|
6314
|
+
this.unmappedRanges += resolved.unmappedRanges;
|
|
6315
|
+
for (const raw of resolved.sources) {
|
|
6316
|
+
const source = this.classify(raw);
|
|
6317
|
+
if (source.kind === "unresolved") this.unresolvedSources++;
|
|
6318
|
+
else if (source.kind === "dependency") this.excludedDependencies++;
|
|
6319
|
+
else this.files.add(this.sourceOf(source.path));
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
/** Collection died mid-spec; the shorter file set must say so. */
|
|
6323
|
+
markStopped() {
|
|
6324
|
+
this.stopped = true;
|
|
6325
|
+
this.flush();
|
|
6326
|
+
}
|
|
6327
|
+
/**
|
|
6328
|
+
* Written after every batch that changed something, not only at the end: a
|
|
6329
|
+
* spec that fails mid-way still leaves everything it reached, and a failing
|
|
6330
|
+
* spec is exactly when the reader wants to know what ran.
|
|
6331
|
+
*/
|
|
6332
|
+
flush() {
|
|
6333
|
+
const payload = {
|
|
6334
|
+
specId: this.specId,
|
|
6335
|
+
files: [...this.files].sort(),
|
|
6336
|
+
unmappedScripts: this.unmappedScripts,
|
|
6337
|
+
unmappedRanges: this.unmappedRanges,
|
|
6338
|
+
unresolvedSources: this.unresolvedSources,
|
|
6339
|
+
excludedDependencies: this.excludedDependencies,
|
|
6340
|
+
stopped: this.stopped
|
|
6341
|
+
};
|
|
6342
|
+
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
6343
|
+
if (text === this.written) return;
|
|
6344
|
+
try {
|
|
6345
|
+
if (!this.dirReady) {
|
|
6346
|
+
mkdirSync(this.coverageDir, { recursive: true });
|
|
6347
|
+
this.dirReady = true;
|
|
6348
|
+
}
|
|
6349
|
+
writeFileSync(join(this.coverageDir, FRONTEND_COVERAGE_FILE), text, "utf8");
|
|
6350
|
+
this.written = text;
|
|
6351
|
+
} catch (error) {
|
|
6352
|
+
this.warn(`could not write ${FRONTEND_COVERAGE_FILE} (${message$2(error)})`);
|
|
6353
|
+
}
|
|
6354
|
+
}
|
|
6355
|
+
classify(raw) {
|
|
6356
|
+
const known = this.classified.get(raw);
|
|
6357
|
+
if (known !== void 0) return known;
|
|
6358
|
+
const source = normalizeSourcePath(raw, this.roots);
|
|
6359
|
+
this.classified.set(raw, source);
|
|
6360
|
+
return source;
|
|
6361
|
+
}
|
|
6362
|
+
sourceOf(path) {
|
|
6363
|
+
const known = this.sources.get(path);
|
|
6364
|
+
if (known !== void 0) return known;
|
|
6365
|
+
const source = sourceBehindBuildOutput(path, this.roots.root) ?? path;
|
|
6366
|
+
this.sources.set(path, source);
|
|
6367
|
+
return source;
|
|
6368
|
+
}
|
|
6369
|
+
/**
|
|
6370
|
+
* Restricted to bundler schemes: a real `http(s)` URL is a built asset, and
|
|
6371
|
+
* its path says nothing about the sources inside it.
|
|
6372
|
+
*/
|
|
6373
|
+
bundlerModulePath(url) {
|
|
6374
|
+
if (url === "" || /^https?:/i.test(url) || !url.includes("://")) return void 0;
|
|
6375
|
+
const source = this.classify(url);
|
|
6376
|
+
if (source.kind !== "project" || !SOURCE_FILE.test(source.path)) return void 0;
|
|
6377
|
+
return source.path;
|
|
6378
|
+
}
|
|
6379
|
+
async loadSourceMap(script) {
|
|
6380
|
+
const cached = this.maps.get(script.url);
|
|
6381
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
6382
|
+
const prepared = await this.fetchSourceMap(script);
|
|
6383
|
+
this.maps.set(script.url, prepared ?? null);
|
|
6384
|
+
return prepared;
|
|
6385
|
+
}
|
|
6386
|
+
async fetchSourceMap(script) {
|
|
6387
|
+
const source = await script.source();
|
|
6388
|
+
if (source === void 0) return void 0;
|
|
6389
|
+
const reference = readSourceMappingUrl(source);
|
|
6390
|
+
if (reference === void 0) return void 0;
|
|
6391
|
+
let json = decodeInlineSourceMap(reference);
|
|
6392
|
+
if (json === void 0) {
|
|
6393
|
+
let target;
|
|
6394
|
+
try {
|
|
6395
|
+
target = new URL(reference, script.url).toString();
|
|
6396
|
+
} catch {
|
|
6397
|
+
return;
|
|
6398
|
+
}
|
|
6399
|
+
json = await this.fetchText(target);
|
|
6400
|
+
if (json === void 0) return void 0;
|
|
6401
|
+
}
|
|
6402
|
+
const map = parseSourceMap(json);
|
|
6403
|
+
if (map === void 0) return void 0;
|
|
6404
|
+
return prepareSourceMap(map, source);
|
|
6405
|
+
}
|
|
6406
|
+
};
|
|
6407
|
+
function message$2(error) {
|
|
6408
|
+
return error instanceof Error ? error.message : String(error);
|
|
6409
|
+
}
|
|
6410
|
+
//#endregion
|
|
6411
|
+
//#region src/coverage/browser/cdp.ts
|
|
6412
|
+
/**
|
|
6413
|
+
* Minimal Chrome DevTools Protocol client, dependency-free on purpose.
|
|
6414
|
+
*
|
|
6415
|
+
* Coverage acquisition speaks a handful of domains over one transport, which
|
|
6416
|
+
* is not enough to justify a protocol library in a published CLI. The
|
|
6417
|
+
* transport is the `WebSocket` global — stable since Node 22 — so availability
|
|
6418
|
+
* is gated with an explicit error instead of a package.json engines bump:
|
|
6419
|
+
* everything else in ccqa still runs on 20, and only `--coverage`'s browser
|
|
6420
|
+
* half needs more.
|
|
6421
|
+
*/
|
|
6422
|
+
var CdpError = class extends Error {};
|
|
6423
|
+
/**
|
|
6424
|
+
* Wire-level trace, for diagnosing the engine against a live browser:
|
|
6425
|
+
* `CCQA_CDP_TRACE=1` writes to stderr, `CCQA_CDP_TRACE_FILE=<path>` to a file.
|
|
6426
|
+
* The file is the usable one during a live run, whose stderr already carries
|
|
6427
|
+
* the agent's narration.
|
|
6428
|
+
*/
|
|
6429
|
+
const TRACE_FILE = process.env.CCQA_CDP_TRACE_FILE;
|
|
6430
|
+
const TRACE = process.env.CCQA_CDP_TRACE === "1" || TRACE_FILE !== void 0;
|
|
6431
|
+
function trace(direction, text) {
|
|
6432
|
+
if (!TRACE) return;
|
|
6433
|
+
const line = `[cdp ${direction}] ${Date.now() % 1e5} ${text}\n`;
|
|
6434
|
+
if (TRACE_FILE === void 0) {
|
|
6435
|
+
process.stderr.write(line);
|
|
6436
|
+
return;
|
|
6437
|
+
}
|
|
6438
|
+
try {
|
|
6439
|
+
appendFileSync(TRACE_FILE, line);
|
|
6440
|
+
} catch {}
|
|
6441
|
+
}
|
|
6442
|
+
/** Throws with the actual requirement when the runtime cannot open the socket. */
|
|
6443
|
+
function requireWebSocket() {
|
|
6444
|
+
if (typeof WebSocket === "undefined") throw new CdpError(`browser coverage needs the WebSocket global (node 22+); this is node ${process.version}`);
|
|
6445
|
+
}
|
|
6446
|
+
/**
|
|
6447
|
+
* Resolves whatever a target hands us — `host:port`, an `http://` endpoint, or
|
|
6448
|
+
* a ws URL — to the **browser-level** ws endpoint. A page-level ws URL is not
|
|
6449
|
+
* enough: auto-attach has to be armed at the browser to see every page and
|
|
6450
|
+
* every popup, so a page URL is reduced to its host and re-resolved through
|
|
6451
|
+
* `/json/version` like the rest.
|
|
6452
|
+
*/
|
|
6453
|
+
async function browserWebSocketUrl(endpoint) {
|
|
6454
|
+
const trimmed = endpoint.trim();
|
|
6455
|
+
if (/^wss?:\/\//i.test(trimmed) && trimmed.includes("/devtools/browser/")) return trimmed;
|
|
6456
|
+
let host;
|
|
6457
|
+
try {
|
|
6458
|
+
host = new URL(/^[a-z+]+:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`).host;
|
|
6459
|
+
} catch {
|
|
6460
|
+
throw new CdpError(`not a CDP endpoint: "${endpoint}"`);
|
|
6461
|
+
}
|
|
6462
|
+
const version = await fetch(`http://${host}/json/version`).catch((error) => {
|
|
6463
|
+
throw new CdpError(`CDP endpoint ${host} did not answer /json/version (${message$1(error)})`);
|
|
6464
|
+
});
|
|
6465
|
+
if (!version.ok) throw new CdpError(`CDP endpoint ${host} answered ${version.status}`);
|
|
6466
|
+
const body = await version.json();
|
|
6467
|
+
if (typeof body.webSocketDebuggerUrl !== "string") throw new CdpError(`CDP endpoint ${host} reported no webSocketDebuggerUrl`);
|
|
6468
|
+
return body.webSocketDebuggerUrl;
|
|
6469
|
+
}
|
|
6470
|
+
var CdpClient = class CdpClient {
|
|
6471
|
+
ws;
|
|
6472
|
+
nextId = 1;
|
|
6473
|
+
pending = /* @__PURE__ */ new Map();
|
|
6474
|
+
listeners = /* @__PURE__ */ new Map();
|
|
6475
|
+
closeHandlers = /* @__PURE__ */ new Set();
|
|
6476
|
+
constructor(ws) {
|
|
6477
|
+
this.ws = ws;
|
|
6478
|
+
ws.addEventListener("message", (event) => this.receive(String(event.data)));
|
|
6479
|
+
ws.addEventListener("close", () => this.drop("connection closed"));
|
|
6480
|
+
ws.addEventListener("error", () => this.drop("connection error"));
|
|
6481
|
+
}
|
|
6482
|
+
static async connect(wsUrl) {
|
|
6483
|
+
requireWebSocket();
|
|
6484
|
+
const ws = new WebSocket(wsUrl);
|
|
6485
|
+
await new Promise((resolve, reject) => {
|
|
6486
|
+
ws.addEventListener("open", () => resolve(), { once: true });
|
|
6487
|
+
ws.addEventListener("error", () => reject(new CdpError(`could not connect to ${wsUrl}`)), { once: true });
|
|
6488
|
+
});
|
|
6489
|
+
return new CdpClient(ws);
|
|
6490
|
+
}
|
|
6491
|
+
send(method, params, sessionId) {
|
|
6492
|
+
if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new CdpError(`${method}: connection closed`));
|
|
6493
|
+
const id = this.nextId++;
|
|
6494
|
+
const promise = new Promise((resolve, reject) => {
|
|
6495
|
+
this.pending.set(id, {
|
|
6496
|
+
resolve,
|
|
6497
|
+
reject,
|
|
6498
|
+
method
|
|
6499
|
+
});
|
|
6500
|
+
});
|
|
6501
|
+
trace("->", `#${id} ${method} sid:${shortId(sessionId)}`);
|
|
6502
|
+
this.ws.send(JSON.stringify({
|
|
6503
|
+
id,
|
|
6504
|
+
method,
|
|
6505
|
+
params: params ?? {},
|
|
6506
|
+
sessionId
|
|
6507
|
+
}));
|
|
6508
|
+
return promise;
|
|
6509
|
+
}
|
|
6510
|
+
on(method, handler) {
|
|
6511
|
+
let set = this.listeners.get(method);
|
|
6512
|
+
if (set === void 0) {
|
|
6513
|
+
set = /* @__PURE__ */ new Set();
|
|
6514
|
+
this.listeners.set(method, set);
|
|
6515
|
+
}
|
|
6516
|
+
set.add(handler);
|
|
6517
|
+
}
|
|
6518
|
+
onClose(handler) {
|
|
6519
|
+
this.closeHandlers.add(handler);
|
|
6520
|
+
}
|
|
6521
|
+
close() {
|
|
6522
|
+
try {
|
|
6523
|
+
this.ws.close();
|
|
6524
|
+
} catch {}
|
|
6525
|
+
}
|
|
6526
|
+
receive(data) {
|
|
6527
|
+
let parsed;
|
|
6528
|
+
try {
|
|
6529
|
+
parsed = JSON.parse(data);
|
|
6530
|
+
} catch {
|
|
6531
|
+
trace("rx", `unparseable frame: ${data.slice(0, 60)}`);
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
if (parsed.id !== void 0) {
|
|
6535
|
+
const waiting = this.pending.get(parsed.id);
|
|
6536
|
+
if (waiting === void 0) return;
|
|
6537
|
+
this.pending.delete(parsed.id);
|
|
6538
|
+
if (parsed.error !== void 0) {
|
|
6539
|
+
trace("<-", `#${parsed.id} ${waiting.method} ERROR ${parsed.error.message ?? "?"}`);
|
|
6540
|
+
waiting.reject(new CdpError(`${waiting.method}: ${parsed.error.message ?? "CDP error"}`));
|
|
6541
|
+
} else {
|
|
6542
|
+
trace("<-", `#${parsed.id} ${waiting.method} ok`);
|
|
6543
|
+
waiting.resolve(parsed.result ?? {});
|
|
6544
|
+
}
|
|
6545
|
+
return;
|
|
6546
|
+
}
|
|
6547
|
+
if (parsed.method !== void 0) {
|
|
6548
|
+
trace("ev", describeEvent(parsed.method, parsed.params ?? {}, parsed.sessionId));
|
|
6549
|
+
const set = this.listeners.get(parsed.method);
|
|
6550
|
+
if (set === void 0) return;
|
|
6551
|
+
for (const handler of set) try {
|
|
6552
|
+
handler(parsed.params ?? {}, parsed.sessionId);
|
|
6553
|
+
} catch {}
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
drop(reason) {
|
|
6557
|
+
for (const waiting of this.pending.values()) waiting.reject(new CdpError(`${waiting.method}: ${reason}`));
|
|
6558
|
+
this.pending.clear();
|
|
6559
|
+
for (const handler of this.closeHandlers) try {
|
|
6560
|
+
handler();
|
|
6561
|
+
} catch {}
|
|
6562
|
+
this.closeHandlers.clear();
|
|
6563
|
+
}
|
|
6564
|
+
};
|
|
6565
|
+
/** Attach events carry the one thing a method name cannot: what was handed over. */
|
|
6566
|
+
function describeEvent(method, params, sessionId) {
|
|
6567
|
+
const base = `${method} sid:${shortId(sessionId)}`;
|
|
6568
|
+
if (method !== "Target.attachedToTarget") return base;
|
|
6569
|
+
const info = params;
|
|
6570
|
+
return `${base} child:${shortId(info.sessionId)} ${info.targetInfo?.type ?? "?"} ${info.targetInfo?.url?.slice(0, 40) ?? "?"} wait:${String(info.waitingForDebugger)}`;
|
|
6571
|
+
}
|
|
6572
|
+
function shortId(sessionId) {
|
|
6573
|
+
return sessionId?.slice(0, 6) ?? "-";
|
|
6574
|
+
}
|
|
6575
|
+
function message$1(error) {
|
|
6576
|
+
return error instanceof Error ? error.message : String(error);
|
|
6577
|
+
}
|
|
6578
|
+
//#endregion
|
|
6579
|
+
//#region src/coverage/browser/engine.ts
|
|
6580
|
+
const TAKE_INTERVAL_MS = 400;
|
|
6581
|
+
/** How long a navigation may hold takes before the guard assumes a lost event. */
|
|
6582
|
+
const NAVIGATION_GUARD_MS = 5e3;
|
|
6583
|
+
/** How long `stop()` waits for the final take before declaring the tail lost. */
|
|
6584
|
+
const STOP_TAKE_TIMEOUT_MS = 2e3;
|
|
6585
|
+
/** The browser's own chrome. Nothing there is the application under test. */
|
|
6586
|
+
const INTERNAL_URL = /^(chrome|chrome-untrusted|chrome-extension|devtools):/;
|
|
6587
|
+
async function startBrowserCoverage(opts) {
|
|
6588
|
+
const client = await (opts.connect ?? ((wsUrl) => CdpClient.connect(wsUrl)))(await browserWebSocketUrl(opts.cdpUrl));
|
|
6589
|
+
const engine = new Engine(client, opts);
|
|
6590
|
+
try {
|
|
6591
|
+
await engine.arm();
|
|
6592
|
+
} catch (error) {
|
|
6593
|
+
client.close();
|
|
6594
|
+
throw error;
|
|
6595
|
+
}
|
|
6596
|
+
return engine;
|
|
6597
|
+
}
|
|
6598
|
+
var Engine = class {
|
|
6599
|
+
client;
|
|
6600
|
+
opts;
|
|
6601
|
+
resolution;
|
|
6602
|
+
pages = /* @__PURE__ */ new Map();
|
|
6603
|
+
/**
|
|
6604
|
+
* Targets already armed, by target id. Browser-level auto-attach reports
|
|
6605
|
+
* existing targets too, so the explicit sweep for them can hand over the
|
|
6606
|
+
* same target a second time, and a page armed through two sessions is taken
|
|
6607
|
+
* twice for one set of counters.
|
|
6608
|
+
*/
|
|
6609
|
+
armedTargets = /* @__PURE__ */ new Set();
|
|
6610
|
+
/** Sessions whose take failure was already said; see enqueueTake. */
|
|
6611
|
+
warnedTakeSessions = /* @__PURE__ */ new Set();
|
|
6612
|
+
cookies;
|
|
6613
|
+
timer;
|
|
6614
|
+
stopped = false;
|
|
6615
|
+
constructor(client, opts) {
|
|
6616
|
+
this.client = client;
|
|
6617
|
+
this.opts = opts;
|
|
6618
|
+
this.cookies = opts.origins.map((url) => ({
|
|
6619
|
+
name: COVERAGE_COOKIE,
|
|
6620
|
+
value: opts.specId,
|
|
6621
|
+
url
|
|
6622
|
+
}));
|
|
6623
|
+
this.resolution = new FrontendResolution({
|
|
6624
|
+
specId: opts.specId,
|
|
6625
|
+
coverageDir: opts.coverageDir,
|
|
6626
|
+
roots: opts.roots,
|
|
6627
|
+
fetchText: (url) => this.fetchThroughBrowser(url),
|
|
6628
|
+
warn: opts.warn
|
|
6629
|
+
});
|
|
6630
|
+
}
|
|
6631
|
+
async arm() {
|
|
6632
|
+
this.client.on("Target.attachedToTarget", (params) => {
|
|
6633
|
+
this.onAttached(params);
|
|
6634
|
+
});
|
|
6635
|
+
this.client.on("Target.detachedFromTarget", (params) => {
|
|
6636
|
+
const sessionId = params.sessionId;
|
|
6637
|
+
if (sessionId === void 0) return;
|
|
6638
|
+
const page = this.pages.get(sessionId);
|
|
6639
|
+
this.pages.delete(sessionId);
|
|
6640
|
+
if (page !== void 0) this.armedTargets.delete(page.targetId);
|
|
6641
|
+
});
|
|
6642
|
+
this.client.on("Page.frameStartedNavigating", (params, sessionId) => {
|
|
6643
|
+
if (sessionId === void 0) return;
|
|
6644
|
+
const page = this.pages.get(sessionId);
|
|
6645
|
+
if (page === void 0 || !this.isMainFrame(page, params.frameId)) return;
|
|
6646
|
+
page.navigatingSince = Date.now();
|
|
6647
|
+
});
|
|
6648
|
+
this.client.on("Page.frameNavigated", (params, sessionId) => {
|
|
6649
|
+
if (sessionId === void 0) return;
|
|
6650
|
+
const page = this.pages.get(sessionId);
|
|
6651
|
+
if (page === void 0) return;
|
|
6652
|
+
const frame = params.frame;
|
|
6653
|
+
if (frame?.parentId !== void 0) return;
|
|
6654
|
+
if (frame?.id !== void 0) page.mainFrameId = frame.id;
|
|
6655
|
+
page.navigatingSince = void 0;
|
|
6656
|
+
this.setCookies(page);
|
|
6657
|
+
});
|
|
6658
|
+
this.client.on("Page.frameStoppedLoading", (params, sessionId) => {
|
|
6659
|
+
if (sessionId === void 0) return;
|
|
6660
|
+
const page = this.pages.get(sessionId);
|
|
6661
|
+
if (page === void 0 || !this.isMainFrame(page, params.frameId)) return;
|
|
6662
|
+
page.navigatingSince = void 0;
|
|
6663
|
+
});
|
|
6664
|
+
this.client.onClose(() => {
|
|
6665
|
+
if (!this.stopped && this.pages.size > 0) this.resolution.markStopped();
|
|
6666
|
+
this.pages.clear();
|
|
6667
|
+
if (this.timer !== void 0) clearInterval(this.timer);
|
|
6668
|
+
});
|
|
6669
|
+
await this.client.send("Target.setAutoAttach", {
|
|
6670
|
+
autoAttach: true,
|
|
6671
|
+
waitForDebuggerOnStart: false,
|
|
6672
|
+
flatten: true,
|
|
6673
|
+
filter: [{ type: "tab" }, { exclude: true }]
|
|
6674
|
+
});
|
|
6675
|
+
const existing = await this.client.send("Target.getTargets", { filter: [{ type: "tab" }] });
|
|
6676
|
+
for (const info of existing.targetInfos) {
|
|
6677
|
+
if (this.armedTargets.has(info.targetId)) continue;
|
|
6678
|
+
await this.client.send("Target.attachToTarget", {
|
|
6679
|
+
targetId: info.targetId,
|
|
6680
|
+
flatten: true
|
|
6681
|
+
}).catch(() => void 0);
|
|
6682
|
+
}
|
|
6683
|
+
this.timer = setInterval(() => {
|
|
6684
|
+
for (const page of this.pages.values()) {
|
|
6685
|
+
this.enqueueTake(page.sessionId);
|
|
6686
|
+
if (page.armed) this.setCookies(page);
|
|
6687
|
+
}
|
|
6688
|
+
}, TAKE_INTERVAL_MS);
|
|
6689
|
+
this.timer.unref?.();
|
|
6690
|
+
}
|
|
6691
|
+
async stop() {
|
|
6692
|
+
this.stopped = true;
|
|
6693
|
+
if (this.timer !== void 0) clearInterval(this.timer);
|
|
6694
|
+
let sawEverything = ![...this.pages.values()].some((page) => !page.armed);
|
|
6695
|
+
for (const page of this.pages.values()) page.navigatingSince = void 0;
|
|
6696
|
+
const takes = Promise.all([...this.pages.keys()].map((sessionId) => this.enqueueTake(sessionId)));
|
|
6697
|
+
if (await Promise.race([takes.then(() => false), new Promise((resolve) => setTimeout(resolve, STOP_TAKE_TIMEOUT_MS, true))])) {
|
|
6698
|
+
this.opts.warn("the final coverage take did not answer; the spec's tail went unseen");
|
|
6699
|
+
sawEverything = false;
|
|
6700
|
+
}
|
|
6701
|
+
if (!sawEverything) this.resolution.markStopped();
|
|
6702
|
+
this.resolution.flush();
|
|
6703
|
+
this.client.close();
|
|
6704
|
+
}
|
|
6705
|
+
async onAttached(params) {
|
|
6706
|
+
const { sessionId, targetInfo } = params;
|
|
6707
|
+
const release = () => params.waitingForDebugger ? this.client.send("Runtime.runIfWaitingForDebugger", {}, sessionId).catch(() => void 0) : Promise.resolve();
|
|
6708
|
+
if (this.stopped) {
|
|
6709
|
+
await release();
|
|
6710
|
+
return;
|
|
6711
|
+
}
|
|
6712
|
+
if (targetInfo.type === "tab") {
|
|
6713
|
+
if (!this.armedTargets.has(targetInfo.targetId)) {
|
|
6714
|
+
this.armedTargets.add(targetInfo.targetId);
|
|
6715
|
+
await this.client.send("Target.setAutoAttach", {
|
|
6716
|
+
autoAttach: true,
|
|
6717
|
+
waitForDebuggerOnStart: true,
|
|
6718
|
+
flatten: true
|
|
6719
|
+
}, sessionId).catch(() => void 0);
|
|
6720
|
+
}
|
|
6721
|
+
await release();
|
|
6722
|
+
return;
|
|
6723
|
+
}
|
|
6724
|
+
const measurable = (targetInfo.type === "page" || targetInfo.type === "iframe") && !INTERNAL_URL.test(targetInfo.url);
|
|
6725
|
+
if (!measurable || this.armedTargets.has(targetInfo.targetId)) {
|
|
6726
|
+
await release();
|
|
6727
|
+
if (measurable) await this.client.send("Target.detachFromTarget", { sessionId }).catch(() => void 0);
|
|
6728
|
+
return;
|
|
6729
|
+
}
|
|
6730
|
+
this.armedTargets.add(targetInfo.targetId);
|
|
6731
|
+
const page = {
|
|
6732
|
+
sessionId,
|
|
6733
|
+
targetId: targetInfo.targetId,
|
|
6734
|
+
armed: false,
|
|
6735
|
+
navigatingSince: void 0,
|
|
6736
|
+
mainFrameId: void 0,
|
|
6737
|
+
pending: Promise.resolve()
|
|
6738
|
+
};
|
|
6739
|
+
this.pages.set(sessionId, page);
|
|
6740
|
+
const sent = [
|
|
6741
|
+
this.client.send("Profiler.enable", {}, sessionId),
|
|
6742
|
+
this.client.send("Profiler.startPreciseCoverage", {
|
|
6743
|
+
callCount: true,
|
|
6744
|
+
detailed: true
|
|
6745
|
+
}, sessionId).then(() => {
|
|
6746
|
+
page.armed = true;
|
|
6747
|
+
}),
|
|
6748
|
+
this.client.send("Page.enable", {}, sessionId),
|
|
6749
|
+
this.setCookies(page)
|
|
6750
|
+
];
|
|
6751
|
+
await release();
|
|
6752
|
+
const failed = (await Promise.allSettled(sent)).find((r) => r.status === "rejected");
|
|
6753
|
+
if (failed !== void 0) this.opts.warn(`could not arm a browser target (${message(failed.reason)})`);
|
|
6754
|
+
}
|
|
6755
|
+
setCookies(page) {
|
|
6756
|
+
if (this.cookies.length === 0) return Promise.resolve();
|
|
6757
|
+
return this.client.send("Network.setCookies", { cookies: this.cookies }, page.sessionId).catch((error) => {
|
|
6758
|
+
this.opts.warn(`could not attach the spec cookie (${message(error)})`);
|
|
6759
|
+
});
|
|
6760
|
+
}
|
|
6761
|
+
enqueueTake(sessionId) {
|
|
6762
|
+
const page = this.pages.get(sessionId);
|
|
6763
|
+
if (page === void 0 || !page.armed) return Promise.resolve();
|
|
6764
|
+
if (page.navigatingSince !== void 0) {
|
|
6765
|
+
if (Date.now() - page.navigatingSince < NAVIGATION_GUARD_MS) return Promise.resolve();
|
|
6766
|
+
page.navigatingSince = void 0;
|
|
6767
|
+
}
|
|
6768
|
+
page.pending = page.pending.then(async () => {
|
|
6769
|
+
const taken = await this.client.send("Profiler.takePreciseCoverage", {}, sessionId);
|
|
6770
|
+
await this.absorbEntries(taken.result);
|
|
6771
|
+
}).catch((error) => {
|
|
6772
|
+
if (this.stopped || this.warnedTakeSessions.has(sessionId)) return;
|
|
6773
|
+
this.warnedTakeSessions.add(sessionId);
|
|
6774
|
+
if (this.pages.has(sessionId)) this.opts.warn(`a coverage take failed and later ones may too (${message(error)})`);
|
|
6775
|
+
});
|
|
6776
|
+
return page.pending;
|
|
6777
|
+
}
|
|
6778
|
+
/** Face value until the main frame's id is known; see the arm() comment. */
|
|
6779
|
+
isMainFrame(page, frameId) {
|
|
6780
|
+
return page.mainFrameId === void 0 || frameId === void 0 || frameId === page.mainFrameId;
|
|
6781
|
+
}
|
|
6782
|
+
async absorbEntries(entries) {
|
|
6783
|
+
let changed = false;
|
|
6784
|
+
for (const entry of entries) {
|
|
6785
|
+
const ranges = [];
|
|
6786
|
+
for (const fn of entry.functions) for (const range of fn.ranges) if (range.count > 0) ranges.push({
|
|
6787
|
+
startOffset: range.startOffset,
|
|
6788
|
+
endOffset: range.endOffset
|
|
6789
|
+
});
|
|
6790
|
+
if (ranges.length === 0) continue;
|
|
6791
|
+
const script = {
|
|
6792
|
+
url: entry.url,
|
|
6793
|
+
ranges,
|
|
6794
|
+
source: async () => /^https?:/i.test(entry.url) ? this.fetchThroughBrowser(entry.url) : void 0
|
|
6795
|
+
};
|
|
6796
|
+
await this.resolution.absorb(script);
|
|
6797
|
+
changed = true;
|
|
6798
|
+
}
|
|
6799
|
+
if (changed) this.resolution.flush();
|
|
6800
|
+
}
|
|
6801
|
+
/**
|
|
6802
|
+
* Fetches through a page where there is one, so the request carries the
|
|
6803
|
+
* session's cookies (see `FrontendResolutionOptions.fetchText`).
|
|
6804
|
+
*/
|
|
6805
|
+
async fetchThroughBrowser(url) {
|
|
6806
|
+
const [page] = this.pages.values();
|
|
6807
|
+
if (page !== void 0) try {
|
|
6808
|
+
const tree = await this.client.send("Page.getFrameTree", {}, page.sessionId);
|
|
6809
|
+
const loaded = await this.client.send("Network.loadNetworkResource", {
|
|
6810
|
+
frameId: tree.frameTree.frame.id,
|
|
6811
|
+
url,
|
|
6812
|
+
options: {
|
|
6813
|
+
disableCache: false,
|
|
6814
|
+
includeCredentials: true
|
|
6815
|
+
}
|
|
6816
|
+
}, page.sessionId);
|
|
6817
|
+
if (loaded.resource.success && loaded.resource.stream !== void 0) return await this.readStream(loaded.resource.stream, page.sessionId);
|
|
6818
|
+
} catch {}
|
|
6819
|
+
try {
|
|
6820
|
+
const response = await fetch(url);
|
|
6821
|
+
if (!response.ok) return void 0;
|
|
6822
|
+
return await response.text();
|
|
6823
|
+
} catch {
|
|
6824
|
+
return;
|
|
6825
|
+
}
|
|
6826
|
+
}
|
|
6827
|
+
async readStream(handle, sessionId) {
|
|
6828
|
+
const parts = [];
|
|
6829
|
+
for (;;) {
|
|
6830
|
+
const chunk = await this.client.send("IO.read", { handle }, sessionId);
|
|
6831
|
+
parts.push(chunk.base64Encoded === true ? Buffer.from(chunk.data, "base64").toString("utf8") : chunk.data);
|
|
6832
|
+
if (chunk.eof) break;
|
|
6833
|
+
}
|
|
6834
|
+
await this.client.send("IO.close", { handle }, sessionId).catch(() => void 0);
|
|
6835
|
+
return parts.join("");
|
|
6836
|
+
}
|
|
6837
|
+
};
|
|
6838
|
+
function message(error) {
|
|
6839
|
+
return error instanceof Error ? error.message : String(error);
|
|
6840
|
+
}
|
|
6841
|
+
//#endregion
|
|
6842
|
+
//#region src/coverage/universe.ts
|
|
6843
|
+
/**
|
|
6844
|
+
* Directories that hold generated or vendored code, not sources anyone writes
|
|
6845
|
+
* tests against. Dot-directories (.git, .next, .turbo…) are skipped wholesale.
|
|
6846
|
+
*/
|
|
6847
|
+
const SKIP_DIRS = new Set([
|
|
6848
|
+
"node_modules",
|
|
6849
|
+
"dist",
|
|
6850
|
+
"build",
|
|
6851
|
+
"out",
|
|
6852
|
+
"coverage"
|
|
6853
|
+
]);
|
|
6854
|
+
/**
|
|
6855
|
+
* More files than any human will triage as a gap list — a ceiling this high is
|
|
6856
|
+
* only reached when `coverage.include` points at something like a whole
|
|
6857
|
+
* monorepo, and a truncated universe would silently misreport "uncovered".
|
|
6858
|
+
*/
|
|
6859
|
+
const MAX_FILES = 2e4;
|
|
6860
|
+
async function enumerateUniverse(root, include, warn) {
|
|
6861
|
+
const files = [];
|
|
6862
|
+
const dirs = [...new Set(include.map(normalizeDir))];
|
|
6863
|
+
for (const dir of dirs) {
|
|
6864
|
+
await walk(dir === "" ? root : join(root, dir), dir, files, warn);
|
|
6865
|
+
if (files.length > MAX_FILES) {
|
|
6866
|
+
warn(`coverage.include matched more than ${MAX_FILES} files — the universe was omitted rather than truncated. Narrow coverage.include to the directories the measurement covers.`);
|
|
6867
|
+
return;
|
|
6868
|
+
}
|
|
6869
|
+
}
|
|
6870
|
+
if (files.length === 0) {
|
|
6871
|
+
warn("coverage.include matched no files — the universe was omitted. Check that the directories exist relative to coverage.projectRoot.");
|
|
6872
|
+
return;
|
|
6873
|
+
}
|
|
6874
|
+
files.sort();
|
|
6875
|
+
return {
|
|
6876
|
+
include: [...include],
|
|
6877
|
+
files
|
|
6878
|
+
};
|
|
6879
|
+
}
|
|
6880
|
+
function normalizeDir(dir) {
|
|
6881
|
+
const posix = dir.replaceAll("\\", "/").replace(/^(\.\/)+/, "").replace(/\/+$/, "");
|
|
6882
|
+
return posix === "." ? "" : posix;
|
|
6883
|
+
}
|
|
6884
|
+
async function walk(abs, rel, out, warn) {
|
|
6885
|
+
let entries;
|
|
6886
|
+
try {
|
|
6887
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
6888
|
+
} catch (err) {
|
|
6889
|
+
warn(`coverage universe: cannot read ${abs} (${err.code ?? String(err)}) — its files are not counted.`);
|
|
6890
|
+
return;
|
|
6891
|
+
}
|
|
6892
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
6893
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
|
|
6894
|
+
await walk(join(abs, entry.name), rel === "" ? entry.name : `${rel}/${entry.name}`, out, warn);
|
|
6895
|
+
} else if (entry.isFile() && SOURCE_FILE.test(entry.name)) out.push(rel === "" ? entry.name : `${rel}/${entry.name}`);
|
|
6896
|
+
}
|
|
6897
|
+
//#endregion
|
|
6898
|
+
//#region src/coverage/session.ts
|
|
6899
|
+
/**
|
|
6900
|
+
* One run's coverage measurement: the sink the application pushes to, the
|
|
6901
|
+
* acquisition engine each spec's browser is armed with, and the merge of what
|
|
6902
|
+
* both sides reported into a report row.
|
|
6903
|
+
*
|
|
6904
|
+
* The two sides never talk to each other. The browser writes what it reached
|
|
6905
|
+
* into the spec's coverage directory; instrumented server processes push what
|
|
6906
|
+
* they reached here. They meet on the spec id the cookie carried between them.
|
|
6907
|
+
*/
|
|
6908
|
+
/**
|
|
6909
|
+
* The application pushes on a timer, so the last second of a spec is still in
|
|
6910
|
+
* flight when its test returns — and the tail of a spec is where the work it
|
|
6911
|
+
* triggered asynchronously lands.
|
|
6912
|
+
*/
|
|
6913
|
+
const SETTLE_POLL_MS = 250;
|
|
6914
|
+
const SETTLE_QUIET_POLLS = 10;
|
|
6915
|
+
const SETTLE_CAP_MS = 1e4;
|
|
6916
|
+
var CoverageSession = class CoverageSession {
|
|
6917
|
+
existing = /* @__PURE__ */ new Map();
|
|
6918
|
+
sink;
|
|
6919
|
+
runId;
|
|
6920
|
+
/** What reported paths are relative to, and what they are checked against. */
|
|
6921
|
+
root;
|
|
6922
|
+
/** Where ccqa runs — the engine's base for resolving bundler-relative paths. */
|
|
6923
|
+
cwd;
|
|
6924
|
+
actors;
|
|
6925
|
+
origins;
|
|
6926
|
+
/** The denominator, enumerated once at start, or undefined when `coverage.include` is unset. */
|
|
6927
|
+
universe;
|
|
6928
|
+
constructor(sink, runId, root, cwd, actors, origins, universe) {
|
|
6929
|
+
this.sink = sink;
|
|
6930
|
+
this.runId = runId;
|
|
6931
|
+
this.root = root;
|
|
6932
|
+
this.cwd = cwd;
|
|
6933
|
+
this.actors = actors;
|
|
6934
|
+
this.origins = origins;
|
|
6935
|
+
this.universe = universe;
|
|
6936
|
+
}
|
|
6937
|
+
static async start(options) {
|
|
6938
|
+
const origins = options.config.instrumentedOrigins.map((origin) => resolveEnvRefs(origin));
|
|
6939
|
+
const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
|
|
6940
|
+
if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
|
|
6941
|
+
const bind = new URL(resolveEnvRefs(options.config.sink));
|
|
6942
|
+
const actors = options.actors ?? NO_ACTORS;
|
|
6943
|
+
const issued = new Set(options.specs.map((spec) => specIdFor(options.runId, spec)));
|
|
6944
|
+
const sink = await CoverageSink.start(bind.hostname, bind.port === "" ? 80 : Number(bind.port), issued, actors.tagToKey);
|
|
6945
|
+
const root = await resolveRoot(options.cwd, options.config.projectRoot) ?? options.cwd;
|
|
6946
|
+
const universe = options.config.include === void 0 ? void 0 : await enumerateUniverse(root, options.config.include, (text) => warn(text));
|
|
6947
|
+
return new CoverageSession(sink, options.runId, root, options.cwd, actors, origins, universe);
|
|
6948
|
+
}
|
|
6949
|
+
get sinkUrl() {
|
|
6950
|
+
return this.sink.url;
|
|
6951
|
+
}
|
|
6952
|
+
/**
|
|
6953
|
+
* Opens the spec's measurement.
|
|
6954
|
+
*
|
|
6955
|
+
* Waits out the drain first, when the spec acts as an identity another spec
|
|
6956
|
+
* just finished acting as: the two clocks involved make an event near the
|
|
6957
|
+
* boundary ambiguous, and a quiet gap is the only thing that resolves it
|
|
6958
|
+
* without either side having to trust the other's time.
|
|
6959
|
+
*/
|
|
6960
|
+
async beginSpec(ref) {
|
|
6961
|
+
const specId = specIdFor(this.runId, ref);
|
|
6962
|
+
for (const window of this.actors.windowsForSpec.get(specKey(ref)) ?? []) {
|
|
6963
|
+
const closedAt = this.sink.lastClosedAt(window.tag);
|
|
6964
|
+
const wait = closedAt === void 0 ? 0 : closedAt + ACTOR_DRAIN_MS - Date.now();
|
|
6965
|
+
if (wait > 0) {
|
|
6966
|
+
meta("coverage", `waiting ${Math.ceil(wait / 1e3)}s for ${window.key} to go quiet`);
|
|
6967
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
6968
|
+
}
|
|
6969
|
+
this.sink.openWindow(window, specId);
|
|
6970
|
+
}
|
|
6971
|
+
}
|
|
6972
|
+
/**
|
|
6973
|
+
* Attaches the acquisition engine to the browser the spec's target drives.
|
|
6974
|
+
* Everything spec-specific the engine needs — the id, the cookie's
|
|
6975
|
+
* destinations, the roots — lives here, so the caller only supplies where
|
|
6976
|
+
* the browser is.
|
|
6977
|
+
*/
|
|
6978
|
+
armBrowser(ref, cdpUrl, coverageDir) {
|
|
6979
|
+
return startBrowserCoverage({
|
|
6980
|
+
cdpUrl,
|
|
6981
|
+
specId: specIdFor(this.runId, ref),
|
|
6982
|
+
origins: this.origins,
|
|
6983
|
+
coverageDir,
|
|
6984
|
+
roots: {
|
|
6985
|
+
base: this.cwd,
|
|
6986
|
+
root: this.root
|
|
6987
|
+
},
|
|
6988
|
+
warn: (text) => warn(`coverage: ${text}`)
|
|
6989
|
+
});
|
|
6990
|
+
}
|
|
6991
|
+
/** Merges both sides once the spec's pushes have stopped arriving. */
|
|
6992
|
+
async collect(ref, coverageDir) {
|
|
6993
|
+
const specId = specIdFor(this.runId, ref);
|
|
6994
|
+
await this.settle(specId);
|
|
6995
|
+
const owned = this.actors.windowsForSpec.get(specKey(ref)) ?? [];
|
|
6996
|
+
for (const window of owned) this.sink.closeWindow(window.tag);
|
|
6997
|
+
const matched = this.sink.actorEventsFor(specId);
|
|
6998
|
+
const backend = this.sink.filesFor(specId);
|
|
6999
|
+
const frontend = await readFrontend(coverageDir, specId);
|
|
7000
|
+
const inProject = await this.keepExisting(frontend?.files ?? []);
|
|
7001
|
+
return {
|
|
7002
|
+
files: [...new Set([...backend ?? [], ...inProject])].sort(),
|
|
7003
|
+
frontendFiles: inProject.length,
|
|
7004
|
+
backendFiles: backend?.size ?? 0,
|
|
7005
|
+
backendReported: this.sink.heardFromApplication(),
|
|
7006
|
+
frontendReported: frontend !== void 0,
|
|
7007
|
+
frontendStopped: frontend?.stopped ?? false,
|
|
7008
|
+
actorWindows: owned.map((window) => ({
|
|
7009
|
+
key: window.key,
|
|
7010
|
+
events: matched.get(window.key) ?? 0
|
|
7011
|
+
})),
|
|
7012
|
+
excludedDependencies: frontend?.excludedDependencies ?? 0,
|
|
7013
|
+
gaps: {
|
|
7014
|
+
unattributed: this.sink.unattributedFor(specId),
|
|
7015
|
+
unmappedScripts: frontend?.unmappedScripts ?? 0,
|
|
7016
|
+
unmappedRanges: frontend?.unmappedRanges ?? 0,
|
|
7017
|
+
outsideProject: (frontend?.files.length ?? 0) - inProject.length,
|
|
7018
|
+
unresolvedSources: frontend?.unresolvedSources ?? 0,
|
|
7019
|
+
uninstrumentedFiles: this.sink.uninstrumentedFiles(),
|
|
7020
|
+
uninstrumentedProcesses: this.sink.uninstrumentedProcesses(),
|
|
7021
|
+
droppedPushes: this.sink.droppedPushes(),
|
|
7022
|
+
unmappedActorEvents: this.sink.unmappedActorEvents(),
|
|
7023
|
+
outsideWindowEvents: owned.reduce((sum, window) => sum + (this.sink.outsideWindowEvents().get(window.key) ?? 0), 0)
|
|
7024
|
+
}
|
|
7025
|
+
};
|
|
7026
|
+
}
|
|
7027
|
+
/** Files reached at module top level, across the whole run. */
|
|
7028
|
+
boot() {
|
|
7029
|
+
return [...this.sink.boot()].sort();
|
|
7030
|
+
}
|
|
7031
|
+
/** Whether any instrumented application process reported at all. */
|
|
7032
|
+
heardFromApplication() {
|
|
7033
|
+
return this.sink.heardFromApplication();
|
|
7034
|
+
}
|
|
7035
|
+
/** Specs some application process attributed a file to. */
|
|
7036
|
+
attributedSpecs() {
|
|
7037
|
+
return this.sink.attributedSpecs();
|
|
7038
|
+
}
|
|
7039
|
+
/** Declared identities that acted outside the turns this run gave them. */
|
|
7040
|
+
outsideWindowEvents() {
|
|
7041
|
+
return this.sink.outsideWindowEvents();
|
|
7042
|
+
}
|
|
7043
|
+
/** Events from identities this project never declared. */
|
|
7044
|
+
unmappedActorEvents() {
|
|
7045
|
+
return this.sink.unmappedActorEvents();
|
|
7046
|
+
}
|
|
7047
|
+
/** Pushes naming a spec id this run never issued — a stale or forged cookie. */
|
|
7048
|
+
rejectedPushes() {
|
|
7049
|
+
return this.sink.rejectedPushes();
|
|
7050
|
+
}
|
|
7051
|
+
/** Pushes the sink could not read — the two halves' wire formats disagree. */
|
|
7052
|
+
malformedPushes() {
|
|
7053
|
+
return this.sink.malformedPushes();
|
|
7054
|
+
}
|
|
7055
|
+
/** Application processes that instrumented nothing at all. */
|
|
7056
|
+
uninstrumentedProcesses() {
|
|
7057
|
+
return this.sink.uninstrumentedProcesses();
|
|
7058
|
+
}
|
|
7059
|
+
async close() {
|
|
7060
|
+
await this.sink.close();
|
|
7061
|
+
}
|
|
7062
|
+
/** Keeps the paths that name a file in the working tree, cached per session. */
|
|
7063
|
+
async keepExisting(paths) {
|
|
7064
|
+
const unknown = paths.filter((path) => !this.existing.has(path));
|
|
7065
|
+
await Promise.all(unknown.map(async (path) => {
|
|
7066
|
+
this.existing.set(path, await access(join(this.root, path)).then(() => true, () => false));
|
|
7067
|
+
}));
|
|
7068
|
+
return paths.filter((path) => this.existing.get(path) === true);
|
|
7069
|
+
}
|
|
7070
|
+
async settle(specId) {
|
|
7071
|
+
if (!this.sink.heardFromApplication()) return;
|
|
7072
|
+
const deadline = Date.now() + SETTLE_CAP_MS;
|
|
7073
|
+
let previous = this.sink.filesFor(specId)?.size ?? 0;
|
|
7074
|
+
let quiet = 0;
|
|
7075
|
+
while (Date.now() < deadline && quiet < SETTLE_QUIET_POLLS) {
|
|
7076
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
|
|
7077
|
+
const size = this.sink.filesFor(specId)?.size ?? 0;
|
|
7078
|
+
quiet = size === previous ? quiet + 1 : 0;
|
|
7079
|
+
previous = size;
|
|
7080
|
+
}
|
|
7081
|
+
}
|
|
7082
|
+
};
|
|
7083
|
+
/**
|
|
7084
|
+
* Ends a spec's measurement, whatever happened to the spec.
|
|
7085
|
+
*
|
|
7086
|
+
* Every caller has to reach this, including the paths that give up before the
|
|
7087
|
+
* spec runs: a turn opened on an identity and never closed swallows every later
|
|
7088
|
+
* event for it, and the next spec on that identity skips the drain it needs.
|
|
7089
|
+
*
|
|
7090
|
+
* Never throws. A measurement that could not be read is not a test result.
|
|
7091
|
+
*/
|
|
7092
|
+
async function closeMeasurement(collector, ref, coverageDir) {
|
|
7093
|
+
try {
|
|
7094
|
+
return await collector.collect(ref, coverageDir);
|
|
7095
|
+
} catch (error) {
|
|
7096
|
+
warn(`coverage: could not collect for ${specKey(ref)} (${errMessage(error)})`);
|
|
7097
|
+
return;
|
|
7098
|
+
}
|
|
5440
7099
|
}
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
7100
|
+
/**
|
|
7101
|
+
* The configured root, checked before a run leans on it.
|
|
7102
|
+
*
|
|
7103
|
+
* Every failure here is otherwise silent and identical to success: a root that
|
|
7104
|
+
* does not exist, or does not contain the project, sends every relative source
|
|
7105
|
+
* outside it, and the run reports a smaller file set with no error at all —
|
|
7106
|
+
* the answer this measurement exists to prevent.
|
|
7107
|
+
*/
|
|
7108
|
+
async function resolveRoot(cwd, declared) {
|
|
7109
|
+
if (declared === void 0) return void 0;
|
|
7110
|
+
const substituted = resolveEnvRefs(declared).trim();
|
|
7111
|
+
if (substituted === "") throw new Error(`coverage.projectRoot "${declared}" resolved to nothing — is the variable set?`);
|
|
7112
|
+
const root = resolve(cwd, substituted);
|
|
7113
|
+
if ((await stat(root).catch(() => void 0))?.isDirectory() !== true) throw new Error(`coverage.projectRoot must name an existing directory; "${declared}" resolved to ${root}`);
|
|
7114
|
+
if (relative(root, cwd).startsWith("..")) throw new Error(`coverage.projectRoot must contain the directory ccqa runs in; ${root} does not contain ${cwd}`);
|
|
7115
|
+
return root;
|
|
7116
|
+
}
|
|
7117
|
+
/**
|
|
7118
|
+
* Where a spec's browser-side result lands.
|
|
7119
|
+
*
|
|
7120
|
+
* Not the artifacts directory: the tool a target runs owns that one and may
|
|
7121
|
+
* recreate it on startup, and everything left there is also reported as an
|
|
7122
|
+
* artifact — the same measurement would then ship twice, once structured and
|
|
7123
|
+
* once as a raw blob.
|
|
7124
|
+
*/
|
|
7125
|
+
function specCoverageDir(reportDir, feature, spec) {
|
|
7126
|
+
return join(reportDir, "coverage", feature, spec);
|
|
5444
7127
|
}
|
|
5445
|
-
//#endregion
|
|
5446
|
-
//#region src/run/output-tail.ts
|
|
5447
|
-
/** Cap on the per-spec output tail kept for the report / analysis prompt. */
|
|
5448
|
-
const OUTPUT_TAIL_CAP = 64 * 1024;
|
|
5449
7128
|
/**
|
|
5450
|
-
*
|
|
5451
|
-
*
|
|
5452
|
-
*
|
|
5453
|
-
* runner (src/targets/run-command-runner.ts) can use it without importing the
|
|
5454
|
-
* whole pipeline.
|
|
7129
|
+
* `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
|
|
7130
|
+
* run out; the spec half is `specKey`, so an id here and a report row name the
|
|
7131
|
+
* same spec the same way.
|
|
5455
7132
|
*/
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
7133
|
+
function specIdFor(runId, ref) {
|
|
7134
|
+
return `${runId}.${specKey(ref)}`;
|
|
7135
|
+
}
|
|
7136
|
+
async function readFrontend(coverageDir, specId) {
|
|
7137
|
+
let raw;
|
|
7138
|
+
try {
|
|
7139
|
+
raw = await readFile(join(coverageDir, FRONTEND_COVERAGE_FILE), "utf8");
|
|
7140
|
+
} catch {
|
|
7141
|
+
return;
|
|
5465
7142
|
}
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
7143
|
+
try {
|
|
7144
|
+
const parsed = JSON.parse(raw);
|
|
7145
|
+
if (!Array.isArray(parsed.files) || parsed.specId !== specId) throw new Error("not this spec");
|
|
7146
|
+
return parsed;
|
|
7147
|
+
} catch (error) {
|
|
7148
|
+
warn(`coverage: ${FRONTEND_COVERAGE_FILE} for ${specId} could not be read (${errMessage(error)})`);
|
|
7149
|
+
return;
|
|
5469
7150
|
}
|
|
5470
|
-
}
|
|
7151
|
+
}
|
|
5471
7152
|
//#endregion
|
|
5472
7153
|
//#region src/report/spec-row.ts
|
|
5473
7154
|
/**
|
|
@@ -5600,10 +7281,10 @@ function toPosix(p) {
|
|
|
5600
7281
|
*/
|
|
5601
7282
|
function substituteRunCommandFiles(runCommand, testFiles) {
|
|
5602
7283
|
if (!runCommand.includes("{files}")) return runCommand;
|
|
5603
|
-
const joined = testFiles.map(shellQuote).join(" ");
|
|
7284
|
+
const joined = testFiles.map(shellQuote$1).join(" ");
|
|
5604
7285
|
return runCommand.replaceAll("{files}", joined);
|
|
5605
7286
|
}
|
|
5606
|
-
function shellQuote(s) {
|
|
7287
|
+
function shellQuote$1(s) {
|
|
5607
7288
|
return /^[A-Za-z0-9_./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`;
|
|
5608
7289
|
}
|
|
5609
7290
|
/**
|
|
@@ -5729,21 +7410,70 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
5729
7410
|
});
|
|
5730
7411
|
await mkdir(evidenceDir, { recursive: true });
|
|
5731
7412
|
}
|
|
5732
|
-
const
|
|
7413
|
+
const measurement = opts.coverage !== void 0 && opts.browserCoverage.browser === "cdp" ? {
|
|
7414
|
+
collector: opts.coverage,
|
|
7415
|
+
cdpEndpoint: opts.browserCoverage.cdpEndpoint
|
|
7416
|
+
} : null;
|
|
7417
|
+
const coverageDir = specCoverageDir(opts.reportDir, featureName, specName);
|
|
7418
|
+
if (measurement) {
|
|
7419
|
+
await rm(coverageDir, {
|
|
7420
|
+
recursive: true,
|
|
7421
|
+
force: true
|
|
7422
|
+
});
|
|
7423
|
+
await mkdir(coverageDir, { recursive: true });
|
|
7424
|
+
}
|
|
7425
|
+
const childEnv = {
|
|
7426
|
+
[ARTIFACTS_DIR_ENV]: artifactsDir,
|
|
7427
|
+
CCQA_RUN_ID: buildRunId(),
|
|
7428
|
+
...evidenceDir ? { [EVIDENCE_DIR_ENV]: evidenceDir } : {}
|
|
7429
|
+
};
|
|
7430
|
+
let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
|
|
7431
|
+
let browserHandle;
|
|
7432
|
+
let browserEngine;
|
|
7433
|
+
let attachError;
|
|
7434
|
+
if (measurement) {
|
|
7435
|
+
await measurement.collector.beginSpec(ref);
|
|
7436
|
+
try {
|
|
7437
|
+
browserHandle = await measurement.cdpEndpoint({
|
|
7438
|
+
cwd: opts.cwd,
|
|
7439
|
+
featureName,
|
|
7440
|
+
specName
|
|
7441
|
+
});
|
|
7442
|
+
const acquired = browserHandle;
|
|
7443
|
+
opts.teardown?.onFinalize(() => acquired.dispose());
|
|
7444
|
+
browserEngine = await measurement.collector.armBrowser(ref, browserHandle.cdpUrl, coverageDir);
|
|
7445
|
+
if (browserHandle.amendCommand) command = browserHandle.amendCommand(command);
|
|
7446
|
+
Object.assign(childEnv, browserHandle.env);
|
|
7447
|
+
} catch (err) {
|
|
7448
|
+
attachError = errMessage(err);
|
|
7449
|
+
warn(`coverage: could not attach to the target's browser (${attachError})`);
|
|
7450
|
+
}
|
|
7451
|
+
}
|
|
5733
7452
|
meta("command", command);
|
|
5734
7453
|
blank();
|
|
5735
7454
|
const started = Date.now();
|
|
5736
7455
|
let outcome;
|
|
7456
|
+
let spawnFailure;
|
|
7457
|
+
let measured;
|
|
5737
7458
|
try {
|
|
5738
7459
|
outcome = await runShellCommand$1(command, {
|
|
5739
7460
|
cwd: opts.cwd,
|
|
5740
7461
|
artifactsDir,
|
|
5741
|
-
|
|
5742
|
-
|
|
7462
|
+
logPath: join(artifactsDir, OUTPUT_LOG_FILE),
|
|
7463
|
+
env: childEnv
|
|
5743
7464
|
});
|
|
5744
7465
|
} catch (err) {
|
|
5745
|
-
|
|
7466
|
+
spawnFailure = err instanceof Error ? err.message : String(err);
|
|
7467
|
+
} finally {
|
|
7468
|
+
if (browserEngine) await browserEngine.stop().catch(() => void 0);
|
|
7469
|
+
if (measurement) measured = await closeMeasurement(measurement.collector, ref, coverageDir);
|
|
7470
|
+
if (browserHandle) await browserHandle.dispose().catch(() => void 0);
|
|
5746
7471
|
}
|
|
7472
|
+
const coverageFields = coverageRowFields(opts, measured, attachError);
|
|
7473
|
+
if (spawnFailure !== void 0 || outcome === void 0) return {
|
|
7474
|
+
...didNotExecute(`could not spawn runCommand: ${spawnFailure ?? "unknown error"}`, "the runCommand could not be spawned"),
|
|
7475
|
+
...coverageFields
|
|
7476
|
+
};
|
|
5747
7477
|
const durationMs = Date.now() - started;
|
|
5748
7478
|
blank();
|
|
5749
7479
|
let artifacts;
|
|
@@ -5770,16 +7500,29 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
5770
7500
|
target: opts.targetId,
|
|
5771
7501
|
durationMs,
|
|
5772
7502
|
...artifactFields,
|
|
5773
|
-
...evidenceFields
|
|
7503
|
+
...evidenceFields,
|
|
7504
|
+
...coverageFields
|
|
5774
7505
|
};
|
|
5775
7506
|
return {
|
|
5776
7507
|
...failedRow([`command failed (exit ${outcome.exitCode}): ${command}`, outcome.tail.length > 0 ? `--- output (tail) ---\n${outcome.tail}` : null].filter((p) => p !== null).join("\n")),
|
|
5777
7508
|
durationMs,
|
|
5778
7509
|
...artifactFields,
|
|
5779
|
-
...evidenceFields
|
|
7510
|
+
...evidenceFields,
|
|
7511
|
+
...coverageFields
|
|
5780
7512
|
};
|
|
5781
7513
|
}
|
|
5782
7514
|
/**
|
|
7515
|
+
* What the row says about measurement. A half-measured row (the server side
|
|
7516
|
+
* only, because the browser never attached) would read as "this spec reached
|
|
7517
|
+
* almost nothing", so a failed attach reports its reason instead of numbers.
|
|
7518
|
+
*/
|
|
7519
|
+
function coverageRowFields(opts, measured, attachError) {
|
|
7520
|
+
if (attachError !== void 0) return { coverageUnavailable: `could not attach to the target's browser: ${attachError}` };
|
|
7521
|
+
if (measured !== void 0) return { coverage: measured };
|
|
7522
|
+
if (opts.coverage !== void 0 && opts.browserCoverage.browser === "none") return { coverageUnavailable: opts.browserCoverage.reason };
|
|
7523
|
+
return {};
|
|
7524
|
+
}
|
|
7525
|
+
/**
|
|
5783
7526
|
* The row's step screenshots, or — when there are none — the reason, so the
|
|
5784
7527
|
* report never shows an empty evidence section without explanation. A
|
|
5785
7528
|
* supported target that produced nothing almost always means the generated
|
|
@@ -5863,9 +7606,7 @@ async function runShellCommand$1(command, opts) {
|
|
|
5863
7606
|
shell: true,
|
|
5864
7607
|
env: {
|
|
5865
7608
|
...process.env,
|
|
5866
|
-
|
|
5867
|
-
CCQA_RUN_ID: buildRunId(),
|
|
5868
|
-
...opts.evidenceDir ? { [EVIDENCE_DIR_ENV]: opts.evidenceDir } : {}
|
|
7609
|
+
...opts.env
|
|
5869
7610
|
},
|
|
5870
7611
|
stdio: [
|
|
5871
7612
|
"ignore",
|
|
@@ -7198,6 +8939,12 @@ async function resolveSerialGroups(groups, cwd) {
|
|
|
7198
8939
|
}
|
|
7199
8940
|
return (ref) => bySpec.get(specKey(ref)) ?? [];
|
|
7200
8941
|
}
|
|
8942
|
+
/** Every group either lookup gives a spec. Names from different sources never collide. */
|
|
8943
|
+
function mergeGroups(...lookups) {
|
|
8944
|
+
const present = lookups.filter((lookup) => lookup !== NO_GROUPS);
|
|
8945
|
+
if (present.length <= 1) return present[0] ?? NO_GROUPS;
|
|
8946
|
+
return (ref) => present.flatMap((lookup) => lookup(ref));
|
|
8947
|
+
}
|
|
7201
8948
|
//#endregion
|
|
7202
8949
|
//#region src/hub/contract/schema.ts
|
|
7203
8950
|
/**
|
|
@@ -10286,6 +12033,46 @@ async function copyEvidenceIntoReport(absPath, evidenceDir, reportDir) {
|
|
|
10286
12033
|
}
|
|
10287
12034
|
}
|
|
10288
12035
|
//#endregion
|
|
12036
|
+
//#region src/targets/agent-browser/browser-endpoint.ts
|
|
12037
|
+
/**
|
|
12038
|
+
* Where the agent-browser target's browser comes from under `--coverage`.
|
|
12039
|
+
*
|
|
12040
|
+
* agent-browser owns its browser but hands out the keys on request:
|
|
12041
|
+
* `get cdp-url` answers with the browser-level DevTools socket. The daemon
|
|
12042
|
+
* launches a session's browser lazily, so an `open about:blank` forces it up
|
|
12043
|
+
* first — into a session the caller already owns, before the agent starts
|
|
12044
|
+
* driving it. Auth-state restored into the session afterwards lands in a warm
|
|
12045
|
+
* browser, which is the same shape as the executor's mid-run recovery path.
|
|
12046
|
+
*
|
|
12047
|
+
* `dispose` does nothing on purpose: the session's lifecycle belongs to the
|
|
12048
|
+
* caller (the live runner closes it after the engine has stopped), and
|
|
12049
|
+
* closing somebody else's session from here would tear the browser down
|
|
12050
|
+
* while its owner still thinks it is driving it.
|
|
12051
|
+
*/
|
|
12052
|
+
async function acquireAgentBrowserEndpoint(ctx) {
|
|
12053
|
+
const session = ctx.driverSession;
|
|
12054
|
+
if (session === void 0) throw new Error("the agent-browser target's browser lives in a driver session, and none was supplied");
|
|
12055
|
+
const warm = spawnAB([
|
|
12056
|
+
"--session",
|
|
12057
|
+
session,
|
|
12058
|
+
"open",
|
|
12059
|
+
"about:blank"
|
|
12060
|
+
]);
|
|
12061
|
+
if (warm.status !== 0) throw new Error(`could not start the session's browser: ${warm.stderr || warm.stdout}`);
|
|
12062
|
+
const answer = spawnAB([
|
|
12063
|
+
"--session",
|
|
12064
|
+
session,
|
|
12065
|
+
"get",
|
|
12066
|
+
"cdp-url"
|
|
12067
|
+
]);
|
|
12068
|
+
const cdpUrl = answer.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
12069
|
+
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}`);
|
|
12070
|
+
return {
|
|
12071
|
+
cdpUrl,
|
|
12072
|
+
dispose: async () => {}
|
|
12073
|
+
};
|
|
12074
|
+
}
|
|
12075
|
+
//#endregion
|
|
10289
12076
|
//#region src/diagnose/snapshot.ts
|
|
10290
12077
|
const require = createRequire(import.meta.url);
|
|
10291
12078
|
const SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
@@ -10399,6 +12186,37 @@ async function closeSession(sessionName) {
|
|
|
10399
12186
|
* agent-browser) and, when `reportDir` is set, run drift audit + failure
|
|
10400
12187
|
* analysis to produce report rows. Sibling of `runDeterministicSpecs`.
|
|
10401
12188
|
*/
|
|
12189
|
+
/**
|
|
12190
|
+
* Brackets one live spec's execution with the measurement.
|
|
12191
|
+
*
|
|
12192
|
+
* The bracket has to hold even when the spec does not run: opening a turn on an
|
|
12193
|
+
* identity and never closing it would leave the next spec waiting on a window
|
|
12194
|
+
* that outlived its owner.
|
|
12195
|
+
*/
|
|
12196
|
+
async function measureLive(spec, opts, coverageDir, execute) {
|
|
12197
|
+
const collector = opts.coverage;
|
|
12198
|
+
if (collector === void 0) return {
|
|
12199
|
+
outcome: await execute(),
|
|
12200
|
+
coverage: void 0
|
|
12201
|
+
};
|
|
12202
|
+
await rm(coverageDir, {
|
|
12203
|
+
recursive: true,
|
|
12204
|
+
force: true
|
|
12205
|
+
});
|
|
12206
|
+
await mkdir(coverageDir, { recursive: true });
|
|
12207
|
+
await collector.beginSpec(spec);
|
|
12208
|
+
let outcome;
|
|
12209
|
+
try {
|
|
12210
|
+
outcome = await execute();
|
|
12211
|
+
} catch (err) {
|
|
12212
|
+
await closeMeasurement(collector, spec, coverageDir);
|
|
12213
|
+
throw err;
|
|
12214
|
+
}
|
|
12215
|
+
return {
|
|
12216
|
+
outcome,
|
|
12217
|
+
coverage: await closeMeasurement(collector, spec, coverageDir)
|
|
12218
|
+
};
|
|
12219
|
+
}
|
|
10402
12220
|
async function runLiveSpecs(specs, opts) {
|
|
10403
12221
|
if (specs.length === 0) return {
|
|
10404
12222
|
reportResults: [],
|
|
@@ -10427,22 +12245,28 @@ async function runLiveSpecs(specs, opts) {
|
|
|
10427
12245
|
blank();
|
|
10428
12246
|
info(`[${i + 1}/${specs.length}] ${label}`);
|
|
10429
12247
|
}
|
|
10430
|
-
const
|
|
12248
|
+
const coverageDir = specCoverageDir(reportDir, spec.featureName, spec.specName);
|
|
12249
|
+
const measured = await measureLive(spec, opts, coverageDir, () => runOneSpec({
|
|
10431
12250
|
...spec,
|
|
10432
12251
|
opts,
|
|
10433
12252
|
userPromptSuffix,
|
|
10434
|
-
cwd
|
|
10435
|
-
|
|
12253
|
+
cwd,
|
|
12254
|
+
coverageDir
|
|
12255
|
+
}));
|
|
12256
|
+
const { outcome } = measured;
|
|
10436
12257
|
if (outcome.kind !== "run") return {
|
|
10437
12258
|
outcome,
|
|
10438
12259
|
row: null
|
|
10439
12260
|
};
|
|
10440
|
-
const row =
|
|
10441
|
-
|
|
10442
|
-
|
|
10443
|
-
|
|
10444
|
-
|
|
10445
|
-
|
|
12261
|
+
const row = {
|
|
12262
|
+
...await buildLiveReportRow(outcome, {
|
|
12263
|
+
auth,
|
|
12264
|
+
diffProvider,
|
|
12265
|
+
reportDir,
|
|
12266
|
+
blocks
|
|
12267
|
+
}, opts, cwd),
|
|
12268
|
+
...outcome.coverageBroken !== void 0 ? { coverageUnavailable: `could not attach to the live browser: ${outcome.coverageBroken}` } : measured.coverage ? { coverage: measured.coverage } : {}
|
|
12269
|
+
};
|
|
10446
12270
|
await opts.report?.upsert(row);
|
|
10447
12271
|
return {
|
|
10448
12272
|
outcome,
|
|
@@ -10572,7 +12396,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
|
|
|
10572
12396
|
};
|
|
10573
12397
|
}
|
|
10574
12398
|
async function runOneSpec(args) {
|
|
10575
|
-
const { featureName, specName, opts, userPromptSuffix, cwd } = args;
|
|
12399
|
+
const { featureName, specName, opts, userPromptSuffix, cwd, coverageDir } = args;
|
|
10576
12400
|
const specDir = getSpecDir(featureName, specName, cwd);
|
|
10577
12401
|
let specContent;
|
|
10578
12402
|
try {
|
|
@@ -10615,6 +12439,23 @@ async function runOneSpec(args) {
|
|
|
10615
12439
|
cleanupSession = resolution.cleanup;
|
|
10616
12440
|
meta("state", spec.session.join(", "));
|
|
10617
12441
|
}
|
|
12442
|
+
let browserEngine;
|
|
12443
|
+
let coverageBroken;
|
|
12444
|
+
if (opts.coverage) try {
|
|
12445
|
+
const handle = await acquireAgentBrowserEndpoint({
|
|
12446
|
+
cwd,
|
|
12447
|
+
featureName,
|
|
12448
|
+
specName,
|
|
12449
|
+
driverSession: sessionName
|
|
12450
|
+
});
|
|
12451
|
+
browserEngine = await opts.coverage.armBrowser({
|
|
12452
|
+
featureName,
|
|
12453
|
+
specName
|
|
12454
|
+
}, handle.cdpUrl, coverageDir);
|
|
12455
|
+
} catch (err) {
|
|
12456
|
+
coverageBroken = errMessage(err);
|
|
12457
|
+
warn(`coverage: could not attach to the live browser (${coverageBroken})`);
|
|
12458
|
+
}
|
|
10618
12459
|
try {
|
|
10619
12460
|
const runId = buildRunId();
|
|
10620
12461
|
const envScrubMap = buildProseEnvScrubMap(spec, expanded, { CCQA_RUN_ID: runId });
|
|
@@ -10651,9 +12492,11 @@ async function runOneSpec(args) {
|
|
|
10651
12492
|
runDir,
|
|
10652
12493
|
specYaml: specContent,
|
|
10653
12494
|
envScrubMap,
|
|
10654
|
-
result
|
|
12495
|
+
result,
|
|
12496
|
+
...coverageBroken === void 0 ? {} : { coverageBroken }
|
|
10655
12497
|
};
|
|
10656
12498
|
} finally {
|
|
12499
|
+
if (browserEngine) await browserEngine.stop().catch(() => void 0);
|
|
10657
12500
|
if (cleanupSession) await cleanupSession();
|
|
10658
12501
|
opts.teardown?.untrackSession(sessionName);
|
|
10659
12502
|
await closeSession(sessionName);
|
|
@@ -10818,6 +12661,39 @@ const TargetConfigSchema = z.object({
|
|
|
10818
12661
|
*/
|
|
10819
12662
|
const SerialGroupsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i, "serial group name must be a slug (letters, digits, '.', '_', '-')"), z.array(z.string().min(1)).min(1));
|
|
10820
12663
|
/**
|
|
12664
|
+
* Which specs act as which external identity, for the flows whose requests
|
|
12665
|
+
* cannot carry a spec id at all.
|
|
12666
|
+
*
|
|
12667
|
+
* A chat platform's webhook is sent by the platform, not the browser, so no
|
|
12668
|
+
* cookie rides along and everything the flow reaches would be unattributed.
|
|
12669
|
+
* What the request does carry is who caused it, and if only one spec is allowed
|
|
12670
|
+
* to act as that identity at a time, "who" plus "when" is enough.
|
|
12671
|
+
*
|
|
12672
|
+
* ```yaml
|
|
12673
|
+
* coverage:
|
|
12674
|
+
* actors:
|
|
12675
|
+
* slack: # the preset's tag prefix
|
|
12676
|
+
* ${TEST_USER_ID}: [chat/create-item, chat/resolve-item]
|
|
12677
|
+
* ```
|
|
12678
|
+
*
|
|
12679
|
+
* The provider name is the prefix the matching preset stamps, and the key is an
|
|
12680
|
+
* identity expression the run's variables resolve. Only the unexpanded text is
|
|
12681
|
+
* ever displayed or used as a lock key, so the identity itself stays out of
|
|
12682
|
+
* reports and the hub.
|
|
12683
|
+
*/
|
|
12684
|
+
const CoverageActorsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i, "actor provider must be a slug (letters, digits, '.', '_', '-')"), z.record(z.string().min(1), z.array(z.string().min(1)).min(1)));
|
|
12685
|
+
/**
|
|
12686
|
+
* Settings for `ccqa run --coverage`, which measures what each spec actually
|
|
12687
|
+
* reached in the application under test.
|
|
12688
|
+
*/
|
|
12689
|
+
const CoverageConfigSchema = z.object({
|
|
12690
|
+
instrumentedOrigins: z.array(z.string().min(1)).min(1),
|
|
12691
|
+
sink: z.string().min(1).default("http://127.0.0.1:4757"),
|
|
12692
|
+
projectRoot: z.string().min(1).optional(),
|
|
12693
|
+
include: z.array(z.string().min(1)).optional(),
|
|
12694
|
+
actors: CoverageActorsSchema.default({})
|
|
12695
|
+
}).strict();
|
|
12696
|
+
/**
|
|
10821
12697
|
* Top-level `.ccqa/config.yaml` schema. `defaultTarget` is used by specs
|
|
10822
12698
|
* with no `target:` of their own. Both defaults make a missing config file
|
|
10823
12699
|
* equivalent to "agent-browser only, no extra settings".
|
|
@@ -10825,7 +12701,8 @@ const SerialGroupsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i,
|
|
|
10825
12701
|
const ProjectConfigSchema = z.object({
|
|
10826
12702
|
defaultTarget: TargetIdSchema.default(AGENT_BROWSER_TARGET),
|
|
10827
12703
|
targets: z.record(TargetIdSchema, TargetConfigSchema).default({}),
|
|
10828
|
-
serialGroups: SerialGroupsSchema.default({})
|
|
12704
|
+
serialGroups: SerialGroupsSchema.default({}),
|
|
12705
|
+
coverage: CoverageConfigSchema.optional()
|
|
10829
12706
|
}).strict();
|
|
10830
12707
|
/** Config file location, relative to the project root (`--cwd`). */
|
|
10831
12708
|
const PROJECT_CONFIG_PATH = ".ccqa/config.yaml";
|
|
@@ -11938,13 +13815,17 @@ const agentBrowserTarget = {
|
|
|
11938
13815
|
id: AGENT_BROWSER_TARGET,
|
|
11939
13816
|
input: "recording",
|
|
11940
13817
|
generate: generateAgentBrowserTest,
|
|
11941
|
-
existingOutput: (ref, cwd) => getTestScript(ref.featureName, ref.specName, cwd)
|
|
13818
|
+
existingOutput: (ref, cwd) => getTestScript(ref.featureName, ref.specName, cwd),
|
|
13819
|
+
browserCoverage: {
|
|
13820
|
+
browser: "cdp",
|
|
13821
|
+
cdpEndpoint: acquireAgentBrowserEndpoint
|
|
13822
|
+
}
|
|
11942
13823
|
};
|
|
11943
13824
|
//#endregion
|
|
11944
13825
|
//#region src/targets/playwright/emit-mechanical.ts
|
|
11945
13826
|
/** Module the emitted step-boundary capture calls import from. */
|
|
11946
13827
|
const STEP_EVIDENCE_MODULE = "ccqa/step-evidence";
|
|
11947
|
-
/** Capture call emitted when a step is entered / closed. Exported for the
|
|
13828
|
+
/** Capture call emitted when a step is entered / closed. Exported for the generation gate. */
|
|
11948
13829
|
const STEP_EVIDENCE_BEFORE = "ccqaStepBefore";
|
|
11949
13830
|
const STEP_EVIDENCE_AFTER = "ccqaStepAfter";
|
|
11950
13831
|
/** The exact boundary call for one step, as emitted and as the gate greps for it. */
|
|
@@ -12162,6 +14043,145 @@ const j = (s) => JSON.stringify(s);
|
|
|
12162
14043
|
*/
|
|
12163
14044
|
const jExpr = (s) => envRefsToJsExpression(s);
|
|
12164
14045
|
//#endregion
|
|
14046
|
+
//#region src/targets/playwright/browser-server.ts
|
|
14047
|
+
/**
|
|
14048
|
+
* Where the playwright target's browser comes from under `--coverage`.
|
|
14049
|
+
*
|
|
14050
|
+
* `playwright test` launches its own browser inside a process ccqa does not
|
|
14051
|
+
* own, and Playwright has no environment knob that would open a debugging
|
|
14052
|
+
* port on it. So the ownership is inverted: ccqa launches a browser server —
|
|
14053
|
+
* with the *consumer's own* Playwright, so the wire protocol matches by
|
|
14054
|
+
* construction — and a generated config makes the tests connect to it via
|
|
14055
|
+
* `use.connectOptions`. The config wrapper imports the project's real config,
|
|
14056
|
+
* so everything else about the run is the project's own; it is written next
|
|
14057
|
+
* to that config because Playwright resolves relative paths against the
|
|
14058
|
+
* config's directory, and a wrapper anywhere else would silently re-root
|
|
14059
|
+
* them.
|
|
14060
|
+
*
|
|
14061
|
+
* The ordering this buys is the point: the browser exists and the engine is
|
|
14062
|
+
* attached before the test process is even spawned, so there is no window in
|
|
14063
|
+
* which a script can run unprofiled or a request can leave uncookied.
|
|
14064
|
+
*/
|
|
14065
|
+
const CONNECT_ENV = "CCQA_PW_CONNECT";
|
|
14066
|
+
const CONFIG_NAMES = [
|
|
14067
|
+
"playwright.config.ts",
|
|
14068
|
+
"playwright.config.mts",
|
|
14069
|
+
"playwright.config.cts",
|
|
14070
|
+
"playwright.config.js",
|
|
14071
|
+
"playwright.config.mjs",
|
|
14072
|
+
"playwright.config.cjs"
|
|
14073
|
+
];
|
|
14074
|
+
async function acquirePlaywrightBrowser(ctx) {
|
|
14075
|
+
await sweepStaleWrappers(ctx.cwd);
|
|
14076
|
+
const chromium = await resolveChromium(ctx.cwd);
|
|
14077
|
+
const port = await freePort();
|
|
14078
|
+
const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
|
|
14079
|
+
let wrapperPath;
|
|
14080
|
+
try {
|
|
14081
|
+
await waitForCdp(port);
|
|
14082
|
+
wrapperPath = await writeWrapperConfig(ctx);
|
|
14083
|
+
} catch (error) {
|
|
14084
|
+
await server.close().catch(() => void 0);
|
|
14085
|
+
throw error;
|
|
14086
|
+
}
|
|
14087
|
+
const wrapper = wrapperPath;
|
|
14088
|
+
let disposed = false;
|
|
14089
|
+
return {
|
|
14090
|
+
cdpUrl: `http://127.0.0.1:${port}`,
|
|
14091
|
+
env: { [CONNECT_ENV]: server.wsEndpoint() },
|
|
14092
|
+
amendCommand: (command) => {
|
|
14093
|
+
if (/(^|\s)(--config|-c)[=\s]/.test(command)) throw new Error("the runCommand already passes --config; --coverage needs to supply its own. Drop the flag from targets.playwright.runCommand (the default config resolution still applies) or run without --coverage.");
|
|
14094
|
+
if (/[|;&<>`$]/.test(command)) throw new Error("the runCommand uses shell operators, so --coverage cannot safely append its --config to it. Reduce targets.playwright.runCommand to a plain `playwright test` invocation or run without --coverage.");
|
|
14095
|
+
return `${command} --config=${shellQuote(wrapper)}`;
|
|
14096
|
+
},
|
|
14097
|
+
dispose: async () => {
|
|
14098
|
+
if (disposed) return;
|
|
14099
|
+
disposed = true;
|
|
14100
|
+
await server.close().catch(() => void 0);
|
|
14101
|
+
await unlink(wrapper).catch(() => void 0);
|
|
14102
|
+
}
|
|
14103
|
+
};
|
|
14104
|
+
}
|
|
14105
|
+
/**
|
|
14106
|
+
* Wrappers a killed earlier run left behind. Deleted on the next acquire, not
|
|
14107
|
+
* only guarded against: a stray one is git-status dirt in somebody's repo.
|
|
14108
|
+
*/
|
|
14109
|
+
async function sweepStaleWrappers(cwd) {
|
|
14110
|
+
const entries = await readdir(cwd).catch(() => []);
|
|
14111
|
+
for (const name of entries) if (name.startsWith("ccqa-coverage.") && name.endsWith(".playwright.config.ts")) await unlink(join(cwd, name)).catch(() => void 0);
|
|
14112
|
+
}
|
|
14113
|
+
/** Single quotes survive every shell metacharacter except themselves. */
|
|
14114
|
+
function shellQuote(s) {
|
|
14115
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
14116
|
+
}
|
|
14117
|
+
/**
|
|
14118
|
+
* The consumer's Playwright, not a dependency of ccqa's: their tests speak
|
|
14119
|
+
* their version's protocol, and the server has to be the same animal. Their
|
|
14120
|
+
* `runCommand` runs `playwright test`, so the package is present — but under
|
|
14121
|
+
* pnpm's isolation it may only be resolvable through `@playwright/test`.
|
|
14122
|
+
*/
|
|
14123
|
+
async function resolveChromium(cwd) {
|
|
14124
|
+
const fromProject = createRequire(join(cwd, "package.json"));
|
|
14125
|
+
const origins = [];
|
|
14126
|
+
try {
|
|
14127
|
+
origins.push(fromProject.resolve("@playwright/test/package.json"));
|
|
14128
|
+
} catch {}
|
|
14129
|
+
for (const name of ["playwright", "playwright-core"]) for (const origin of [null, ...origins]) try {
|
|
14130
|
+
const mod = await import(pathToFileURL((origin === null ? fromProject : createRequire(origin)).resolve(name)).href);
|
|
14131
|
+
const chromium = mod.chromium ?? mod.default?.chromium;
|
|
14132
|
+
if (chromium !== void 0) return chromium;
|
|
14133
|
+
} catch {}
|
|
14134
|
+
throw new Error(`could not resolve Playwright from ${cwd} — the playwright target's --coverage launches the browser with the project's own Playwright, which must be installed`);
|
|
14135
|
+
}
|
|
14136
|
+
async function writeWrapperConfig(ctx) {
|
|
14137
|
+
const existing = CONFIG_NAMES.find((name) => existsSync(join(ctx.cwd, name)));
|
|
14138
|
+
const wrapperName = `ccqa-coverage.${slug(ctx.featureName)}--${slug(ctx.specName)}.playwright.config.ts`;
|
|
14139
|
+
const wrapperPath = join(ctx.cwd, wrapperName);
|
|
14140
|
+
const header = "// Written by `ccqa run --coverage` for one spec's run and removed after it.";
|
|
14141
|
+
const connect = `use: { ...(base as { use?: object }).use, connectOptions: { wsEndpoint: process.env.${CONNECT_ENV} ?? "" } }`;
|
|
14142
|
+
await writeFile(wrapperPath, (existing === void 0 ? [
|
|
14143
|
+
header,
|
|
14144
|
+
`export default { use: { connectOptions: { wsEndpoint: process.env.${CONNECT_ENV} ?? "" } } };`,
|
|
14145
|
+
""
|
|
14146
|
+
] : [
|
|
14147
|
+
header,
|
|
14148
|
+
"// It only points the browser at the server ccqa launched; everything else",
|
|
14149
|
+
"// is the project's own config, imported unchanged.",
|
|
14150
|
+
`import base from "./${existing}";`,
|
|
14151
|
+
`export default { ...(base as object), ${connect} };`,
|
|
14152
|
+
""
|
|
14153
|
+
]).join("\n"), "utf8");
|
|
14154
|
+
return wrapperPath;
|
|
14155
|
+
}
|
|
14156
|
+
function slug(name) {
|
|
14157
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
14158
|
+
}
|
|
14159
|
+
function freePort() {
|
|
14160
|
+
return new Promise((resolve, reject) => {
|
|
14161
|
+
const probe = createServer$1();
|
|
14162
|
+
probe.once("error", reject);
|
|
14163
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
14164
|
+
const address = probe.address();
|
|
14165
|
+
const port = typeof address === "object" && address !== null ? address.port : void 0;
|
|
14166
|
+
probe.close(() => {
|
|
14167
|
+
if (port === void 0) reject(/* @__PURE__ */ new Error("could not pick a port"));
|
|
14168
|
+
else resolve(port);
|
|
14169
|
+
});
|
|
14170
|
+
});
|
|
14171
|
+
});
|
|
14172
|
+
}
|
|
14173
|
+
/** The debugging socket opens with the browser; a short poll absorbs the gap. */
|
|
14174
|
+
async function waitForCdp(port) {
|
|
14175
|
+
const deadline = Date.now() + 5e3;
|
|
14176
|
+
for (;;) {
|
|
14177
|
+
try {
|
|
14178
|
+
if ((await fetch(`http://127.0.0.1:${port}/json/version`)).ok) return;
|
|
14179
|
+
} catch {}
|
|
14180
|
+
if (Date.now() > deadline) throw new Error(`the launched browser never opened its debugging port (${port})`);
|
|
14181
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
14182
|
+
}
|
|
14183
|
+
}
|
|
14184
|
+
//#endregion
|
|
12165
14185
|
//#region src/targets/playwright/index.ts
|
|
12166
14186
|
const PLAYWRIGHT_TARGET = "playwright";
|
|
12167
14187
|
/**
|
|
@@ -12183,6 +14203,10 @@ const playwrightTarget = {
|
|
|
12183
14203
|
existingOutput: existingPlaywrightOutput,
|
|
12184
14204
|
runner: runCommandRunner,
|
|
12185
14205
|
stepEvidence: { supported: true },
|
|
14206
|
+
browserCoverage: {
|
|
14207
|
+
browser: "cdp",
|
|
14208
|
+
cdpEndpoint: acquirePlaywrightBrowser
|
|
14209
|
+
},
|
|
12186
14210
|
guidanceKind: PLAYWRIGHT_TARGET
|
|
12187
14211
|
};
|
|
12188
14212
|
async function generatePlaywrightTest(ctx) {
|
|
@@ -12209,7 +14233,7 @@ async function generatePlaywrightTest(ctx) {
|
|
|
12209
14233
|
path: draftPath,
|
|
12210
14234
|
contents: draft
|
|
12211
14235
|
},
|
|
12212
|
-
|
|
14236
|
+
draftInvariant: stepMarkers.length > 0 ? stepEvidencePreserveRule() : ""
|
|
12213
14237
|
}) : await finalizePreparedFiles({
|
|
12214
14238
|
ctx,
|
|
12215
14239
|
target: PLAYWRIGHT_TARGET,
|
|
@@ -12221,7 +14245,7 @@ async function generatePlaywrightTest(ctx) {
|
|
|
12221
14245
|
summary: `Playwright spec compiled from ${actions.length} recorded action(s)`,
|
|
12222
14246
|
warnings: []
|
|
12223
14247
|
});
|
|
12224
|
-
const missing = await
|
|
14248
|
+
const missing = await missingInjectedCalls(result, stepMarkers);
|
|
12225
14249
|
for (const w of missing) warn(w);
|
|
12226
14250
|
return {
|
|
12227
14251
|
...result,
|
|
@@ -12229,14 +14253,14 @@ async function generatePlaywrightTest(ctx) {
|
|
|
12229
14253
|
};
|
|
12230
14254
|
}
|
|
12231
14255
|
/**
|
|
12232
|
-
*
|
|
12233
|
-
*
|
|
12234
|
-
*
|
|
12235
|
-
*
|
|
12236
|
-
*
|
|
14256
|
+
* Warnings for calls the emitter injected that the written test no longer has.
|
|
14257
|
+
* The deterministic emit always has them; the library-rewrite pass can drop
|
|
14258
|
+
* them when it restructures into page objects, which silently costs the spec
|
|
14259
|
+
* its screenshots. Reads the files from disk (the LLM pass may have relocated
|
|
14260
|
+
* them); a file that can't be read is reported as missing everything rather
|
|
14261
|
+
* than passing silently.
|
|
12237
14262
|
*/
|
|
12238
|
-
async function
|
|
12239
|
-
if (markers.length === 0) return [];
|
|
14263
|
+
async function missingInjectedCalls(result, markers) {
|
|
12240
14264
|
const corpus = (await Promise.all(result.files.filter((f) => f.kind === "test").map((f) => readFile(f.path, "utf8").catch(() => "")))).join("\n");
|
|
12241
14265
|
const warnings = [];
|
|
12242
14266
|
for (const m of markers) {
|
|
@@ -12280,6 +14304,10 @@ const runnTarget = {
|
|
|
12280
14304
|
supported: false,
|
|
12281
14305
|
reason: "runn runs API scenarios, which have no screen to capture"
|
|
12282
14306
|
},
|
|
14307
|
+
browserCoverage: {
|
|
14308
|
+
browser: "none",
|
|
14309
|
+
reason: "runn runs API scenarios; there is no browser to measure"
|
|
14310
|
+
},
|
|
12283
14311
|
guidanceKind: RUNN_TARGET
|
|
12284
14312
|
};
|
|
12285
14313
|
/** Exported with the engine's invoke seam so unit tests can stub Claude. */
|
|
@@ -12434,6 +14462,7 @@ function groupSpecsByTarget(specs, catalog, config, resolve = resolveTarget) {
|
|
|
12434
14462
|
supported: false,
|
|
12435
14463
|
reason: `the "${plugin.id}" target does not capture step screenshots`
|
|
12436
14464
|
},
|
|
14465
|
+
browserCoverage: plugin.browserCoverage,
|
|
12437
14466
|
specs: []
|
|
12438
14467
|
};
|
|
12439
14468
|
group.specs.push(entry);
|
|
@@ -12503,6 +14532,9 @@ async function runExternalSpecs(dispatch, ctx) {
|
|
|
12503
14532
|
targetId: group.targetId,
|
|
12504
14533
|
targetConfig: group.targetConfig,
|
|
12505
14534
|
stepEvidence: group.stepEvidence,
|
|
14535
|
+
browserCoverage: group.browserCoverage,
|
|
14536
|
+
...ctx.coverage ? { coverage: ctx.coverage } : {},
|
|
14537
|
+
...ctx.teardown ? { teardown: ctx.teardown } : {},
|
|
12506
14538
|
onSpecComplete: async (row) => {
|
|
12507
14539
|
streamed.push(row);
|
|
12508
14540
|
await ctx.report.upsert(row);
|
|
@@ -13268,7 +15300,8 @@ async function executeRun(targets, opts) {
|
|
|
13268
15300
|
}
|
|
13269
15301
|
const catalog = await readSpecs(specs, cwd);
|
|
13270
15302
|
const projectConfig = await loadProjectConfig(cwd);
|
|
13271
|
-
const
|
|
15303
|
+
const actors = opts.coverage === true && forExecution ? await resolveActors(projectConfig.coverage?.actors ?? {}, cwd) : NO_ACTORS;
|
|
15304
|
+
const resources = mergeGroups(await resolveSerialGroups(projectConfig.serialGroups, cwd), actorGroups(actors));
|
|
13272
15305
|
const declared = [...new Set(specs.flatMap(resources))];
|
|
13273
15306
|
if (declared.length > 0) meta("serial groups", declared.join(", "));
|
|
13274
15307
|
let waitingOnGroup = [];
|
|
@@ -13311,6 +15344,7 @@ async function executeRun(targets, opts) {
|
|
|
13311
15344
|
const liveSpecs = withMode.filter((s) => s.mode === "live");
|
|
13312
15345
|
meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
|
|
13313
15346
|
if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
|
|
15347
|
+
const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown) : void 0;
|
|
13314
15348
|
if (liveSpecs.length === 0) {
|
|
13315
15349
|
const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
|
|
13316
15350
|
if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
|
|
@@ -13373,7 +15407,8 @@ async function executeRun(targets, opts) {
|
|
|
13373
15407
|
customPromptVersion: customPrompt?.customPromptVersion ?? null,
|
|
13374
15408
|
triageUserPromptHash,
|
|
13375
15409
|
deployedSha,
|
|
13376
|
-
opts
|
|
15410
|
+
opts,
|
|
15411
|
+
coverage
|
|
13377
15412
|
}), hubSink, currentReportCost);
|
|
13378
15413
|
let completedNormally = false;
|
|
13379
15414
|
opts.teardown?.onFinalize(async () => {
|
|
@@ -13397,7 +15432,9 @@ async function executeRun(targets, opts) {
|
|
|
13397
15432
|
resources,
|
|
13398
15433
|
...opts.model ? { model: opts.model } : {},
|
|
13399
15434
|
...opts.language ? { language: opts.language } : {},
|
|
13400
|
-
report: incrementalReport
|
|
15435
|
+
report: incrementalReport,
|
|
15436
|
+
...coverage ? { coverage } : {},
|
|
15437
|
+
...opts.teardown ? { teardown: opts.teardown } : {}
|
|
13401
15438
|
});
|
|
13402
15439
|
const liveOpts = {
|
|
13403
15440
|
...opts.model ? { model: opts.model } : {},
|
|
@@ -13409,6 +15446,7 @@ async function executeRun(targets, opts) {
|
|
|
13409
15446
|
concurrency: opts.concurrency ?? 1,
|
|
13410
15447
|
resources,
|
|
13411
15448
|
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
15449
|
+
...coverage ? { coverage } : {},
|
|
13412
15450
|
diffProvider,
|
|
13413
15451
|
hubContext: hubCtx,
|
|
13414
15452
|
customPrompt,
|
|
@@ -13417,6 +15455,7 @@ async function executeRun(targets, opts) {
|
|
|
13417
15455
|
report: incrementalReport
|
|
13418
15456
|
};
|
|
13419
15457
|
const live = await runLiveSpecs(liveSpecs, liveOpts);
|
|
15458
|
+
if (coverage) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
|
|
13420
15459
|
let overallExitCode = det.exitCode !== 0 ? 1 : 0;
|
|
13421
15460
|
if (live.failedCount > 0) overallExitCode = 1;
|
|
13422
15461
|
if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
|
|
@@ -13432,30 +15471,32 @@ async function executeRun(targets, opts) {
|
|
|
13432
15471
|
}))], analysisDeps);
|
|
13433
15472
|
const detResults = await analyzeDeterministicSummaries(det.summaries, cwd, reportDir, analysisRun);
|
|
13434
15473
|
const analyzedExternalRows = await analyzeExternalRows(externalRows, analysisRun);
|
|
15474
|
+
const results = await rerunExplainedFailures([
|
|
15475
|
+
...detResults,
|
|
15476
|
+
...analyzedExternalRows,
|
|
15477
|
+
...live.reportResults
|
|
15478
|
+
], {
|
|
15479
|
+
mode: rerunMode,
|
|
15480
|
+
maxSpecs: opts.onFailExplainRerunMaxSpecs ?? null,
|
|
15481
|
+
execute: createRerunExecutor({
|
|
15482
|
+
detSpecs,
|
|
15483
|
+
liveSpecs,
|
|
15484
|
+
dispatch,
|
|
15485
|
+
liveOpts,
|
|
15486
|
+
opts,
|
|
15487
|
+
cwd,
|
|
15488
|
+
resources
|
|
15489
|
+
})
|
|
15490
|
+
});
|
|
13435
15491
|
report = await writeUnifiedReport({
|
|
13436
15492
|
reportDir,
|
|
13437
|
-
results:
|
|
13438
|
-
...detResults,
|
|
13439
|
-
...analyzedExternalRows,
|
|
13440
|
-
...live.reportResults
|
|
13441
|
-
], {
|
|
13442
|
-
mode: rerunMode,
|
|
13443
|
-
maxSpecs: opts.onFailExplainRerunMaxSpecs ?? null,
|
|
13444
|
-
execute: createRerunExecutor({
|
|
13445
|
-
detSpecs,
|
|
13446
|
-
liveSpecs,
|
|
13447
|
-
dispatch,
|
|
13448
|
-
liveOpts,
|
|
13449
|
-
opts,
|
|
13450
|
-
cwd,
|
|
13451
|
-
resources
|
|
13452
|
-
})
|
|
13453
|
-
}),
|
|
15493
|
+
results: coverage ? results.map(explainMissingCoverage) : results,
|
|
13454
15494
|
git,
|
|
13455
15495
|
customPromptVersion,
|
|
13456
15496
|
triageUserPromptHash,
|
|
13457
15497
|
deployedSha,
|
|
13458
|
-
opts
|
|
15498
|
+
opts,
|
|
15499
|
+
coverage
|
|
13459
15500
|
});
|
|
13460
15501
|
completedNormally = true;
|
|
13461
15502
|
if (hubRunId) {
|
|
@@ -13465,7 +15506,8 @@ async function executeRun(targets, opts) {
|
|
|
13465
15506
|
customPromptVersion,
|
|
13466
15507
|
triageUserPromptHash,
|
|
13467
15508
|
deployedSha,
|
|
13468
|
-
opts
|
|
15509
|
+
opts,
|
|
15510
|
+
coverage
|
|
13469
15511
|
});
|
|
13470
15512
|
const streamedKeys = new Set(incrementalReport.rows().map((r) => `${r.feature}/${r.spec}`));
|
|
13471
15513
|
const evidence = await readRowsFilesBase64(report.results.filter((r) => !streamedKeys.has(`${r.feature}/${r.spec}`)), reportDir);
|
|
@@ -13504,11 +15546,58 @@ async function executeRun(targets, opts) {
|
|
|
13504
15546
|
};
|
|
13505
15547
|
}
|
|
13506
15548
|
/**
|
|
13507
|
-
*
|
|
13508
|
-
*
|
|
13509
|
-
*
|
|
13510
|
-
|
|
15549
|
+
* Starts the run's coverage measurement before any spec runs: the sink has to
|
|
15550
|
+
* be listening before the first request reaches the application, and the set
|
|
15551
|
+
* of spec ids it will accept is only known once dispatch has resolved.
|
|
15552
|
+
*/
|
|
15553
|
+
async function startCoverage(cwd, config, actors, dispatch, teardown) {
|
|
15554
|
+
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");
|
|
15555
|
+
let session;
|
|
15556
|
+
try {
|
|
15557
|
+
session = await CoverageSession.start({
|
|
15558
|
+
runId: buildRunId(),
|
|
15559
|
+
cwd,
|
|
15560
|
+
config,
|
|
15561
|
+
actors,
|
|
15562
|
+
specs: dispatch.external.flatMap((g) => g.specs)
|
|
15563
|
+
});
|
|
15564
|
+
} catch (err) {
|
|
15565
|
+
throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
|
|
15566
|
+
}
|
|
15567
|
+
meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
|
|
15568
|
+
const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
|
|
15569
|
+
if (unmeasured > 0) warn(`${unmeasured} target(s) declare no browser to measure; their specs are reported as unmeasured rather than as reaching nothing`);
|
|
15570
|
+
teardown?.onFinalize(() => session.close());
|
|
15571
|
+
return session;
|
|
15572
|
+
}
|
|
15573
|
+
/**
|
|
15574
|
+
* A run that measured coverage still leaves rows without any — a spec on a
|
|
15575
|
+
* target that declares no browser, or one that never executed. Saying why keeps
|
|
15576
|
+
* the reader from reading a blank as "this spec reached nothing".
|
|
13511
15577
|
*/
|
|
15578
|
+
function explainMissingCoverage(row) {
|
|
15579
|
+
if (row.coverage !== void 0 || row.coverageUnavailable !== void 0) return row;
|
|
15580
|
+
return {
|
|
15581
|
+
...row,
|
|
15582
|
+
coverageUnavailable: row.status === "skipped" ? "the spec did not execute" : "this target is not measured by --coverage yet"
|
|
15583
|
+
};
|
|
15584
|
+
}
|
|
15585
|
+
/** Everything the measurement could not place; silence here reads as "never reached". */
|
|
15586
|
+
function reportCoverageHealth(coverage, rows) {
|
|
15587
|
+
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");
|
|
15588
|
+
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");
|
|
15589
|
+
const boot = coverage.boot();
|
|
15590
|
+
if (boot.length > 0) meta("coverage", `${boot.length} file(s) reached only at module load, attributed to no spec`);
|
|
15591
|
+
const blind = coverage.uninstrumentedProcesses();
|
|
15592
|
+
if (blind > 0) warn(`${blind} application process(es) instrumented nothing at all — every file they ran is missing from this run, not just some. Load hooks need node 22.15+ (23.5+ on 23.x); bundled code needs the build plugin instead`);
|
|
15593
|
+
for (const [key, count] of coverage.outsideWindowEvents()) warn(`${count} event(s) from ${key} arrived outside this run's turns — another job or a person used that identity while it was being measured, and what they triggered is attributed to nobody`);
|
|
15594
|
+
const unmapped = coverage.unmappedActorEvents();
|
|
15595
|
+
if (unmapped > 0) meta("coverage", `${unmapped} event(s) from identities this project does not declare, attributed to no spec`);
|
|
15596
|
+
const rejected = coverage.rejectedPushes();
|
|
15597
|
+
if (rejected > 0) warn(`${rejected} coverage push(es) named a spec id this run never issued — dropped`);
|
|
15598
|
+
const malformed = coverage.malformedPushes();
|
|
15599
|
+
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`);
|
|
15600
|
+
}
|
|
13512
15601
|
function buildLiveRunSummary(results) {
|
|
13513
15602
|
const sections = [];
|
|
13514
15603
|
for (const r of results) {
|
|
@@ -13780,6 +15869,7 @@ function createRerunExecutor(ctx) {
|
|
|
13780
15869
|
targetId: group.targetId,
|
|
13781
15870
|
targetConfig: group.targetConfig,
|
|
13782
15871
|
stepEvidence: group.stepEvidence,
|
|
15872
|
+
browserCoverage: group.browserCoverage,
|
|
13783
15873
|
onSpecComplete: async () => {}
|
|
13784
15874
|
});
|
|
13785
15875
|
return row?.status === "passed" ? "passed" : "failed";
|
|
@@ -13799,7 +15889,7 @@ function createRerunExecutor(ctx) {
|
|
|
13799
15889
|
* final report.json stays byte-identical (existing e2e goldens compare it).
|
|
13800
15890
|
*/
|
|
13801
15891
|
function buildReportEnvelope(args) {
|
|
13802
|
-
const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
|
|
15892
|
+
const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts, coverage } = args;
|
|
13803
15893
|
const runUrl = githubRunUrl();
|
|
13804
15894
|
return {
|
|
13805
15895
|
schemaVersion: 1,
|
|
@@ -13821,19 +15911,21 @@ function buildReportEnvelope(args) {
|
|
|
13821
15911
|
customPromptVersion,
|
|
13822
15912
|
...triageUserPromptHash !== null ? { triageUserPromptHash } : {},
|
|
13823
15913
|
...deployedSha !== null ? { deployedSha } : {},
|
|
13824
|
-
cost: currentReportCost()
|
|
15914
|
+
cost: currentReportCost(),
|
|
15915
|
+
...coverage?.universe ? { coverageUniverse: coverage.universe } : {}
|
|
13825
15916
|
};
|
|
13826
15917
|
}
|
|
13827
15918
|
/** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
|
|
13828
15919
|
async function writeUnifiedReport(args) {
|
|
13829
|
-
const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
|
|
15920
|
+
const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts, coverage } = args;
|
|
13830
15921
|
const data = {
|
|
13831
15922
|
...buildReportEnvelope({
|
|
13832
15923
|
git,
|
|
13833
15924
|
customPromptVersion,
|
|
13834
15925
|
triageUserPromptHash,
|
|
13835
15926
|
deployedSha,
|
|
13836
|
-
opts
|
|
15927
|
+
opts,
|
|
15928
|
+
coverage
|
|
13837
15929
|
}),
|
|
13838
15930
|
results
|
|
13839
15931
|
};
|
|
@@ -14106,7 +16198,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
|
|
|
14106
16198
|
}, "never").option("--on-fail-explain-rerun-max-specs <n>", "Rerun at most N specs, in report order; the rest are named in the run summary and keep the label they were first given. Default: no cap. The knob for an environment having a bad day, where the alternative is turning the reruns off entirely.", parseRerunMaxSpecs).optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured run results (report.json + evidence PNGs), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Additional output format alongside HTML: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
|
|
14107
16199
|
if (REPORT_FORMATS.includes(raw)) return raw;
|
|
14108
16200
|
throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
|
|
14109
|
-
}, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
|
|
16201
|
+
}, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--coverage", "Measure what each spec actually reached in the application under test, and record it on the spec's report row. Needs a `coverage:` block in .ccqa/config.yaml naming the instrumented origins the spec cookie may go to. The browser half attaches to the target's browser from outside (nothing is emitted into generated tests; needs node 22+); the server half needs the application running with ccqa-tools.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
|
|
14110
16202
|
await runCliAction(targets, opts);
|
|
14111
16203
|
});
|
|
14112
16204
|
/** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
|
|
@@ -18129,7 +20221,8 @@ const PatchRunRequestSchema = z.object({
|
|
|
18129
20221
|
customPromptVersion: z.string().nullable().optional(),
|
|
18130
20222
|
runUrl: z.string().nullable().optional(),
|
|
18131
20223
|
triageUserPromptHash: z.string().optional(),
|
|
18132
|
-
cost: ReportCostSchema.nullable().optional()
|
|
20224
|
+
cost: ReportCostSchema.nullable().optional(),
|
|
20225
|
+
coverageUniverse: CoverageUniverseSchema.optional()
|
|
18133
20226
|
}).partial().optional()
|
|
18134
20227
|
});
|
|
18135
20228
|
/** Insert or replace `rows` into `results`, upserting by feature/spec identity. */
|
|
@@ -18345,7 +20438,8 @@ function createPatchRunHandler(config) {
|
|
|
18345
20438
|
...reportMeta?.customPromptVersion !== void 0 ? { customPromptVersion: reportMeta.customPromptVersion } : {},
|
|
18346
20439
|
...reportMeta?.runUrl !== void 0 ? { runUrl: reportMeta.runUrl } : {},
|
|
18347
20440
|
...reportMeta?.triageUserPromptHash !== void 0 ? { triageUserPromptHash: reportMeta.triageUserPromptHash } : {},
|
|
18348
|
-
...reportMeta?.cost !== void 0 ? { cost: reportMeta.cost } : {}
|
|
20441
|
+
...reportMeta?.cost !== void 0 ? { cost: reportMeta.cost } : {},
|
|
20442
|
+
...reportMeta?.coverageUniverse !== void 0 ? { coverageUniverse: reportMeta.coverageUniverse } : {}
|
|
18349
20443
|
};
|
|
18350
20444
|
const merged = mergeResults(current?.results ?? [], rows);
|
|
18351
20445
|
specs = countSpecs(merged);
|
|
@@ -20245,6 +22339,7 @@ const HTML_BODY = `
|
|
|
20245
22339
|
<nav class="nav">
|
|
20246
22340
|
<a href="#/runs" class="nav-runs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg> <span data-i18n="nav.runs">Runs</span></a>
|
|
20247
22341
|
<a href="#/perspectives" class="nav-perspectives"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg> <span data-i18n="nav.perspectives">Perspectives</span></a>
|
|
22342
|
+
<a href="#/coverage" class="nav-coverage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2"/></svg> <span data-i18n="nav.coverage">Coverage</span></a>
|
|
20248
22343
|
<a href="#/secrets" class="nav-secrets"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <span data-i18n="nav.secrets">Secrets</span></a>
|
|
20249
22344
|
<a href="#/prompts" class="nav-prompts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/><path d="M14 4v6h6"/><path d="M8 13h6M8 17h6"/></svg> <span data-i18n="nav.prompts">Prompts</span></a>
|
|
20250
22345
|
<a href="#/jobs" class="nav-jobs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg> <span data-i18n="nav.learning">Learning</span></a>
|
|
@@ -20369,6 +22464,34 @@ const HTML_BODY = `
|
|
|
20369
22464
|
</div>
|
|
20370
22465
|
</section>
|
|
20371
22466
|
|
|
22467
|
+
<!-- ===== COVERAGE ===== -->
|
|
22468
|
+
<section id="view-coverage" hidden>
|
|
22469
|
+
<div class="page-bar">
|
|
22470
|
+
<h1 data-i18n="coverage.title">Coverage</h1>
|
|
22471
|
+
<span class="updated" id="cov-asof"></span>
|
|
22472
|
+
<div class="spacer"></div>
|
|
22473
|
+
${refreshButton("cov-refresh")}
|
|
22474
|
+
</div>
|
|
22475
|
+
<div class="content">
|
|
22476
|
+
<p id="cov-status" class="empty-note" hidden></p>
|
|
22477
|
+
<div id="cov-body" hidden>
|
|
22478
|
+
<div class="ov">
|
|
22479
|
+
<div class="ov-inv" id="cov-inv"></div>
|
|
22480
|
+
<div id="cov-axis"></div>
|
|
22481
|
+
<div class="cov-note" id="cov-note" hidden data-i18n="coverage.noUniverse">This measurement carried no file inventory, so only reached files are shown; nothing can be called uncovered.</div>
|
|
22482
|
+
</div>
|
|
22483
|
+
<div class="toolbar">
|
|
22484
|
+
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="cov-q" type="search" data-i18n-ph="coverage.search" aria-label="Filter files"></label>
|
|
22485
|
+
<button class="fchip" id="cov-unc" aria-pressed="false" type="button"><span data-i18n="coverage.filter.uncovered">Uncovered only</span><span class="fcount" id="cov-unc-n"></span></button>
|
|
22486
|
+
</div>
|
|
22487
|
+
<div class="cov-split">
|
|
22488
|
+
<div class="cov-treewrap"><div id="cov-tree"></div><p class="empty-note" id="cov-no-hit" hidden data-i18n="coverage.noHit">No matching files.</p></div>
|
|
22489
|
+
<div class="cov-detail" id="cov-detail"></div>
|
|
22490
|
+
</div>
|
|
22491
|
+
</div>
|
|
22492
|
+
</div>
|
|
22493
|
+
</section>
|
|
22494
|
+
|
|
20372
22495
|
<!-- ===== RUN DETAIL ===== -->
|
|
20373
22496
|
<section id="view-detail" hidden>
|
|
20374
22497
|
<div class="page-bar">
|
|
@@ -20737,6 +22860,8 @@ const CSS = `
|
|
|
20737
22860
|
pass/fail ones, and read as a different kind of thing. */
|
|
20738
22861
|
.badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
20739
22862
|
.badge.dr-found .d { background: var(--amber); }
|
|
22863
|
+
.badge.uncov { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
22864
|
+
.badge.uncov .d { background: var(--amber); }
|
|
20740
22865
|
.badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
20741
22866
|
.badge.dr-clean .d { background: var(--pass); }
|
|
20742
22867
|
.badge.dr-unknown { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
@@ -20839,6 +22964,13 @@ const CSS = `
|
|
|
20839
22964
|
.artifact-acc > summary { height: 34px; }
|
|
20840
22965
|
.artifact-pre { margin: 2px 0 8px; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 11.5px; line-height: 1.5; max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
20841
22966
|
.section-label { font-size: 12px; font-weight: 600; color: var(--muted); margin-top: 4px; }
|
|
22967
|
+
/* reached files: what the spec actually executed, plus what could not be placed */
|
|
22968
|
+
.cov-counts { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; }
|
|
22969
|
+
.cov-count { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1px 7px; white-space: nowrap; }
|
|
22970
|
+
.cov-count b { color: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
|
22971
|
+
.cov-count.cov-warn { color: var(--fail); border-color: var(--fail); }
|
|
22972
|
+
.cov-file { font-family: var(--mono); font-size: 12px; padding: 4px 4px; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
22973
|
+
.cov-file:last-child { border-bottom: none; }
|
|
20842
22974
|
/* live run steps: stacked cards with large before/after frames */
|
|
20843
22975
|
.step-card { border: 1px solid var(--border); border-left: 3px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); padding: 12px 14px; margin-top: 10px; }
|
|
20844
22976
|
.step-card.passed { border-left-color: var(--border-strong); }
|
|
@@ -21132,6 +23264,38 @@ const CSS = `
|
|
|
21132
23264
|
.d-note { margin-top: 12px; max-width: 900px; }
|
|
21133
23265
|
|
|
21134
23266
|
.tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
23267
|
+
|
|
23268
|
+
/* ── coverage: file tree ─────────────────────────────────────────── */
|
|
23269
|
+
.cov-note { font-size: 12.5px; color: var(--amber); background: var(--amber-bg); border: 1px solid var(--amber-border); border-radius: var(--radius-md); padding: 8px 12px; }
|
|
23270
|
+
.cov-split { display: grid; grid-template-columns: minmax(360px, 7fr) 5fr; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
|
|
23271
|
+
.cov-treewrap { overflow-y: auto; max-height: 560px; padding: 6px 0; border-right: 1px solid var(--border); }
|
|
23272
|
+
.cov-treewrap ul { list-style: none; margin: 0; padding: 0; }
|
|
23273
|
+
.cov-treewrap ul ul { border-left: 1px solid var(--border); margin-left: 19px; }
|
|
23274
|
+
.cov-row { display: flex; align-items: center; gap: 7px; width: 100%; padding: 3px 14px 3px 8px; border: 0; background: none; text-align: left; font-size: 13px; }
|
|
23275
|
+
.cov-row:hover { background: var(--surface-2); }
|
|
23276
|
+
.cov-row.sel { background: var(--info-bg); box-shadow: inset 2px 0 0 var(--info); }
|
|
23277
|
+
.cov-row .chev { width: 12px; height: 12px; flex: none; color: var(--muted-2); transition: transform 0.15s; }
|
|
23278
|
+
.cov-row .chev.open { transform: rotate(90deg); }
|
|
23279
|
+
.cov-row svg.ic { width: 15px; height: 15px; flex: none; color: var(--muted-2); stroke-width: 1.7; fill: none; stroke: currentColor; }
|
|
23280
|
+
.cov-row .nm { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
23281
|
+
.cov-row .nm.dim { color: var(--muted); }
|
|
23282
|
+
.cov-row .sp { flex: 1; }
|
|
23283
|
+
.cov-row .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
23284
|
+
.cov-row .dot.ok { background: var(--pass); }
|
|
23285
|
+
.cov-row .dot.no { background: var(--amber-fill); }
|
|
23286
|
+
.cov-row .minibar { width: 60px; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; flex: none; }
|
|
23287
|
+
.cov-row .minibar i { display: block; height: 100%; background: var(--pass); }
|
|
23288
|
+
.cov-row .frac { font-size: 11px; color: var(--muted); font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 52px; text-align: right; flex: none; }
|
|
23289
|
+
.cov-detail { padding: 16px 18px; overflow-y: auto; max-height: 560px; }
|
|
23290
|
+
.cov-detail .ph { color: var(--muted-2); font-size: 13px; padding: 30px 10px; text-align: center; }
|
|
23291
|
+
.cov-detail h4 { font-size: 13px; font-family: var(--mono); font-weight: 600; margin: 0 0 4px; word-break: break-all; }
|
|
23292
|
+
.cov-detail .meta { font-size: 12.5px; color: var(--muted); margin-bottom: 12px; }
|
|
23293
|
+
.cov-caselist { border-top: 1px solid var(--border); }
|
|
23294
|
+
.cov-caselist a { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px solid var(--border); text-decoration: none; font-size: 13px; color: inherit; }
|
|
23295
|
+
.cov-caselist a:hover { background: var(--surface-2); }
|
|
23296
|
+
.cov-caselist .cs { font-family: var(--mono); font-size: 12.5px; flex: 1; min-width: 0; word-break: break-all; }
|
|
23297
|
+
.cov-hint { margin-top: 12px; padding: 9px 13px; border-radius: var(--radius-md); background: var(--amber-bg); border: 1px solid var(--amber-border); color: var(--amber); font-size: 12.5px; }
|
|
23298
|
+
@media (max-width: 900px) { .cov-split { grid-template-columns: 1fr; } .cov-treewrap { border-right: 0; border-bottom: 1px solid var(--border); } }
|
|
21135
23299
|
/* Badges across a case row must land on one line. Some of these cells carry
|
|
21136
23300
|
only a badge, others a badge plus a sub-line (sha · when) or a two-line
|
|
21137
23301
|
explanation, so middle-aligning the cells put each badge at a different
|
|
@@ -21238,7 +23402,20 @@ const CLIENT_JS = `
|
|
|
21238
23402
|
var I18N = {
|
|
21239
23403
|
en: {
|
|
21240
23404
|
"nav.projects": "Projects", "nav.runs": "Runs", "nav.perspectives": "Perspectives", "nav.secrets": "Secrets",
|
|
21241
|
-
"nav.prompts": "Prompts", "nav.learning": "Learning",
|
|
23405
|
+
"nav.prompts": "Prompts", "nav.learning": "Learning", "nav.coverage": "Coverage",
|
|
23406
|
+
"coverage.title": "Coverage",
|
|
23407
|
+
"coverage.loading": "Loading coverage…",
|
|
23408
|
+
"coverage.none": "No run with coverage yet. Run with --coverage and push the report to this hub.",
|
|
23409
|
+
"coverage.search": "Filter by file path…",
|
|
23410
|
+
"coverage.filter.uncovered": "Uncovered only",
|
|
23411
|
+
"coverage.reached": "Reached", "coverage.uncovered": "Uncovered", "coverage.files": "files",
|
|
23412
|
+
"coverage.measured": "measured", "coverage.specsCombined": "specs combined",
|
|
23413
|
+
"coverage.noUniverse": "This measurement carried no file inventory, so only reached files are shown; nothing can be called uncovered.",
|
|
23414
|
+
"coverage.placeholder": "Select a file to see the cases that reach it.",
|
|
23415
|
+
"coverage.fileUncovered": "No case reached this file in this measurement.",
|
|
23416
|
+
"coverage.casesReach": "case(s) reach this file",
|
|
23417
|
+
"coverage.noHit": "No matching files.",
|
|
23418
|
+
"coverage.case": "case", "coverage.cases": "cases",
|
|
21242
23419
|
"app.project": "project", "app.profile": "profile", "app.disconnect": "Disconnect", "app.noProject": "no project",
|
|
21243
23420
|
"app.newProfile": "New profile",
|
|
21244
23421
|
"login.title": "Connect to your hub", "login.sub": "Enter your bearer token to continue.",
|
|
@@ -21267,6 +23444,27 @@ const CLIENT_JS = `
|
|
|
21267
23444
|
"acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
|
|
21268
23445
|
"acc.assertions": "Assertions",
|
|
21269
23446
|
"acc.artifacts": "Artifacts",
|
|
23447
|
+
"acc.coverage": "Reached files",
|
|
23448
|
+
"acc.coverage.hint": "Measured, not inferred: V8's counters in the browser and per-request instrumentation on the server.",
|
|
23449
|
+
"cov.frontend": "browser", "cov.backend": "server",
|
|
23450
|
+
"cov.unattributed": "server executions outside this spec's context",
|
|
23451
|
+
"cov.unmappedScripts": "scripts with no usable source map",
|
|
23452
|
+
"cov.unmappedRanges": "executed ranges that mapped nowhere",
|
|
23453
|
+
"cov.outsideProject": "browser sources outside the project",
|
|
23454
|
+
"cov.unresolvedSources": "browser sources that resolved to no project path",
|
|
23455
|
+
"cov.uninstrumentedFiles": "server files that could not be instrumented",
|
|
23456
|
+
"cov.uninstrumentedProcesses": "server processes that instrumented nothing at all",
|
|
23457
|
+
"cov.droppedPushes": "reports the application could not deliver",
|
|
23458
|
+
"cov.unmappedActorEvents": "events from identities this project does not declare",
|
|
23459
|
+
"cov.outsideWindowEvents": "events from a declared identity outside its turn",
|
|
23460
|
+
"cov.excludedDependencies": "dependency sources, excluded on purpose",
|
|
23461
|
+
"cov.route.carrier": "attributed by carrier",
|
|
23462
|
+
"cov.route.actorWindow": "actor-window",
|
|
23463
|
+
"cov.route.events": "events",
|
|
23464
|
+
"cov.noBackend": "no instrumented server process reported",
|
|
23465
|
+
"cov.noFrontend": "the browser produced no result",
|
|
23466
|
+
"cov.frontendStopped": "browser collection stopped early",
|
|
23467
|
+
"cov.unavailable": "Nothing was measured:",
|
|
21270
23468
|
"art.open": "Open", "art.loadFailed": "could not load (it may have been omitted from the push)",
|
|
21271
23469
|
"acc.assertions.hint": "Test cases from the recorded spec run",
|
|
21272
23470
|
"spec.kind.live": "Live", "spec.kind.det": "Deterministic",
|
|
@@ -21427,7 +23625,20 @@ const CLIENT_JS = `
|
|
|
21427
23625
|
},
|
|
21428
23626
|
ja: {
|
|
21429
23627
|
"nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "テスト観点", "nav.secrets": "シークレット",
|
|
21430
|
-
"nav.prompts": "プロンプト", "nav.learning": "学習",
|
|
23628
|
+
"nav.prompts": "プロンプト", "nav.learning": "学習", "nav.coverage": "カバレッジ",
|
|
23629
|
+
"coverage.title": "カバレッジ",
|
|
23630
|
+
"coverage.loading": "カバレッジを読み込み中…",
|
|
23631
|
+
"coverage.none": "カバレッジ付きの run がまだありません。--coverage で計測し、レポートをこの hub に push してください。",
|
|
23632
|
+
"coverage.search": "ファイルパスで絞り込み…",
|
|
23633
|
+
"coverage.filter.uncovered": "未到達のみ",
|
|
23634
|
+
"coverage.reached": "到達", "coverage.uncovered": "未到達", "coverage.files": "ファイル",
|
|
23635
|
+
"coverage.measured": "計測", "coverage.specsCombined": "spec 合算",
|
|
23636
|
+
"coverage.noUniverse": "この計測にはファイル台帳が付いていないため、到達したファイルのみ表示しています。未到達は判定できません。",
|
|
23637
|
+
"coverage.placeholder": "ファイルを選択すると、到達しているケースが表示されます",
|
|
23638
|
+
"coverage.fileUncovered": "この計測では、どのケースもこのファイルに到達しませんでした。",
|
|
23639
|
+
"coverage.casesReach": "ケースが到達",
|
|
23640
|
+
"coverage.noHit": "一致するファイルがありません。",
|
|
23641
|
+
"coverage.case": "ケース", "coverage.cases": "ケース",
|
|
21431
23642
|
"app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
|
|
21432
23643
|
"app.newProfile": "新規プロファイル",
|
|
21433
23644
|
"login.title": "ハブに接続", "login.sub": "続けるにはベアラートークンを入力してください。",
|
|
@@ -21456,6 +23667,27 @@ const CLIENT_JS = `
|
|
|
21456
23667
|
"acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
|
|
21457
23668
|
"acc.assertions": "アサーション",
|
|
21458
23669
|
"acc.artifacts": "成果物",
|
|
23670
|
+
"acc.coverage": "到達ファイル",
|
|
23671
|
+
"acc.coverage.hint": "推定ではなく実測。ブラウザは V8 のカウンタ、サーバはリクエスト単位の計装。",
|
|
23672
|
+
"cov.frontend": "ブラウザ", "cov.backend": "サーバ",
|
|
23673
|
+
"cov.unattributed": "この spec の文脈外で走ったサーバ実行",
|
|
23674
|
+
"cov.unmappedScripts": "source map を辿れなかったスクリプト",
|
|
23675
|
+
"cov.unmappedRanges": "どこにも対応しなかった実行範囲",
|
|
23676
|
+
"cov.outsideProject": "プロジェクト外に解決したブラウザのソース",
|
|
23677
|
+
"cov.unresolvedSources": "プロジェクト内のパスに解決できなかったブラウザのソース",
|
|
23678
|
+
"cov.uninstrumentedFiles": "計装できなかったサーバのファイル",
|
|
23679
|
+
"cov.uninstrumentedProcesses": "何も計装できなかったサーバのプロセス",
|
|
23680
|
+
"cov.droppedPushes": "アプリが送信できなかった報告",
|
|
23681
|
+
"cov.unmappedActorEvents": "このプロジェクトが宣言していない主体のイベント",
|
|
23682
|
+
"cov.outsideWindowEvents": "宣言済みの主体が持ち時間の外で起こしたイベント",
|
|
23683
|
+
"cov.excludedDependencies": "意図的に除外した依存ライブラリのソース",
|
|
23684
|
+
"cov.route.carrier": "carrier で帰属",
|
|
23685
|
+
"cov.route.actorWindow": "actor-window",
|
|
23686
|
+
"cov.route.events": "件",
|
|
23687
|
+
"cov.noBackend": "計装されたサーバプロセスからの報告なし",
|
|
23688
|
+
"cov.noFrontend": "ブラウザ側の結果なし",
|
|
23689
|
+
"cov.frontendStopped": "ブラウザ側の収集が途中で停止",
|
|
23690
|
+
"cov.unavailable": "計測できませんでした:",
|
|
21459
23691
|
"art.open": "開く", "art.loadFailed": "読み込めませんでした(push時に省略された可能性があります)",
|
|
21460
23692
|
"acc.assertions.hint": "記録したスペック実行のテストケース",
|
|
21461
23693
|
"spec.kind.live": "ライブ", "spec.kind.det": "決定的",
|
|
@@ -22070,8 +24302,8 @@ const CLIENT_JS = `
|
|
|
22070
24302
|
|
|
22071
24303
|
// ── view routing ────────────────────────────────────────────────────
|
|
22072
24304
|
|
|
22073
|
-
var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
|
|
22074
|
-
var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives" };
|
|
24305
|
+
var VIEWS = ["projects", "runs", "detail", "perspectives", "coverage", "secrets", "prompts", "jobs"];
|
|
24306
|
+
var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives", coverage: ".nav-coverage" };
|
|
22075
24307
|
function showView(id) {
|
|
22076
24308
|
// Any in-flight job poll belongs to the view we're leaving — bump the token
|
|
22077
24309
|
// so its next tick is a no-op (see pollJob).
|
|
@@ -22090,6 +24322,7 @@ const CLIENT_JS = `
|
|
|
22090
24322
|
var gated = !state.project;
|
|
22091
24323
|
document.querySelector(".nav-runs").classList.toggle("disabled", gated);
|
|
22092
24324
|
document.querySelector(".nav-perspectives").classList.toggle("disabled", gated);
|
|
24325
|
+
document.querySelector(".nav-coverage").classList.toggle("disabled", gated);
|
|
22093
24326
|
document.querySelector(".nav-secrets").classList.toggle("disabled", gated);
|
|
22094
24327
|
document.querySelector(".nav-prompts").classList.toggle("disabled", gated);
|
|
22095
24328
|
document.querySelector(".nav-jobs").classList.toggle("disabled", gated);
|
|
@@ -22106,6 +24339,7 @@ const CLIENT_JS = `
|
|
|
22106
24339
|
var m = location.hash.match(/^#\\/runs\\/(.+)$/);
|
|
22107
24340
|
if (m) { openRunDetail(decodeURIComponent(m[1])); return; }
|
|
22108
24341
|
if (location.hash === "#/perspectives") { openPerspectives(); return; }
|
|
24342
|
+
if (location.hash === "#/coverage") { openCoverage(); return; }
|
|
22109
24343
|
if (location.hash === "#/secrets") { openSecrets(); return; }
|
|
22110
24344
|
if (location.hash === "#/prompts") { openPrompts(); return; }
|
|
22111
24345
|
var j = location.hash.match(/^#\\/jobs\\/(.+)$/);
|
|
@@ -22327,6 +24561,287 @@ const CLIENT_JS = `
|
|
|
22327
24561
|
});
|
|
22328
24562
|
}
|
|
22329
24563
|
|
|
24564
|
+
// == coverage: file tree =============================================
|
|
24565
|
+
// One run's measurement drawn over the enumerated universe. Everything is
|
|
24566
|
+
// display-side aggregation of report.json — the hub computes nothing new.
|
|
24567
|
+
var covState = { q: "", unc: false, model: null, selected: null, openDirs: null, loadToken: 0 };
|
|
24568
|
+
|
|
24569
|
+
function openCoverage() {
|
|
24570
|
+
if (!state.project) { location.hash = "#/projects"; route(); return; }
|
|
24571
|
+
showView("coverage");
|
|
24572
|
+
if (covState.model) { renderCoverage(); return; }
|
|
24573
|
+
loadCoverage();
|
|
24574
|
+
}
|
|
24575
|
+
|
|
24576
|
+
function loadCoverage() {
|
|
24577
|
+
var token = ++covState.loadToken;
|
|
24578
|
+
var status = document.getElementById("cov-status");
|
|
24579
|
+
document.getElementById("cov-body").hidden = true;
|
|
24580
|
+
status.hidden = false;
|
|
24581
|
+
status.textContent = t("coverage.loading");
|
|
24582
|
+
apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
|
|
24583
|
+
.then(function (data) {
|
|
24584
|
+
// A run still in flight has a report, but a partial one: rows trickle
|
|
24585
|
+
// in per spec and the universe only arrives with the seal.
|
|
24586
|
+
var runs = (data.runs || []).filter(function (r) { return r.status !== "running"; });
|
|
24587
|
+
return covFindReport(runs, 0, token);
|
|
24588
|
+
})
|
|
24589
|
+
.then(function (found) {
|
|
24590
|
+
if (token !== covState.loadToken) return;
|
|
24591
|
+
if (!found) { status.textContent = t("coverage.none"); return; }
|
|
24592
|
+
covState.model = covBuildModel(found.run, found.report);
|
|
24593
|
+
covState.openDirs = null;
|
|
24594
|
+
covState.selected = null;
|
|
24595
|
+
status.hidden = true;
|
|
24596
|
+
document.getElementById("cov-body").hidden = false;
|
|
24597
|
+
renderCoverage();
|
|
24598
|
+
})
|
|
24599
|
+
.catch(function (err) {
|
|
24600
|
+
if (token !== covState.loadToken) return;
|
|
24601
|
+
status.textContent = "Error loading coverage: " + err.message;
|
|
24602
|
+
});
|
|
24603
|
+
}
|
|
24604
|
+
|
|
24605
|
+
// Newest first, stop at the first run whose report actually measured.
|
|
24606
|
+
// Capped: each probe is a full report fetch, and past ten stale runs the
|
|
24607
|
+
// answer is "none recent enough to trust" anyway.
|
|
24608
|
+
function covFindReport(runs, i, token) {
|
|
24609
|
+
if (i >= runs.length || i >= 10 || token !== covState.loadToken) return Promise.resolve(null);
|
|
24610
|
+
var run = runs[i];
|
|
24611
|
+
return apiFetch("/api/v1/runs/" + encodeURIComponent(run.id) + "/report").then(function (report) {
|
|
24612
|
+
var measured = !!report.coverageUniverse || (report.results || []).some(function (r) { return r.coverage; });
|
|
24613
|
+
if (measured) return { run: run, report: report };
|
|
24614
|
+
return covFindReport(runs, i + 1, token);
|
|
24615
|
+
}).catch(function () { return covFindReport(runs, i + 1, token); });
|
|
24616
|
+
}
|
|
24617
|
+
|
|
24618
|
+
function covBuildModel(run, report) {
|
|
24619
|
+
// Null-prototype maps throughout: a path segment named "constructor" or
|
|
24620
|
+
// "__proto__" must stay data, not resolve to an inherited property.
|
|
24621
|
+
var byFile = Object.create(null);
|
|
24622
|
+
var measuredSpecs = 0;
|
|
24623
|
+
(report.results || []).forEach(function (row) {
|
|
24624
|
+
if (!row.coverage) return;
|
|
24625
|
+
measuredSpecs++;
|
|
24626
|
+
var key = row.feature + "/" + row.spec;
|
|
24627
|
+
(row.coverage.files || []).forEach(function (path) {
|
|
24628
|
+
(byFile[path] = byFile[path] || []).push(key);
|
|
24629
|
+
});
|
|
24630
|
+
});
|
|
24631
|
+
// universe ∪ reached: the enumeration's filters can never lose a result.
|
|
24632
|
+
var universe = report.coverageUniverse ? report.coverageUniverse.files : null;
|
|
24633
|
+
var all = Object.create(null);
|
|
24634
|
+
(universe || []).forEach(function (path) { all[path] = true; });
|
|
24635
|
+
Object.keys(byFile).forEach(function (path) { all[path] = true; });
|
|
24636
|
+
var root = { name: "", dirs: Object.create(null), files: [], total: 0, covered: 0 };
|
|
24637
|
+
var fileByPath = Object.create(null);
|
|
24638
|
+
Object.keys(all).sort().forEach(function (path) {
|
|
24639
|
+
var parts = path.split("/");
|
|
24640
|
+
var node = root;
|
|
24641
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
24642
|
+
node = node.dirs[parts[i]] = node.dirs[parts[i]] || { name: parts[i], dirs: Object.create(null), files: [], total: 0, covered: 0 };
|
|
24643
|
+
}
|
|
24644
|
+
var f = { name: parts[parts.length - 1], path: path, cases: byFile[path] || [] };
|
|
24645
|
+
node.files.push(f);
|
|
24646
|
+
fileByPath[path] = f;
|
|
24647
|
+
});
|
|
24648
|
+
covAnnotate(root);
|
|
24649
|
+
return { run: run, report: report, root: root, fileByPath: fileByPath, hasUniverse: !!universe, measuredSpecs: measuredSpecs };
|
|
24650
|
+
}
|
|
24651
|
+
|
|
24652
|
+
function covAnnotate(node) {
|
|
24653
|
+
node.total = node.files.length;
|
|
24654
|
+
node.covered = node.files.filter(function (f) { return f.cases.length > 0; }).length;
|
|
24655
|
+
Object.keys(node.dirs).forEach(function (k) {
|
|
24656
|
+
covAnnotate(node.dirs[k]);
|
|
24657
|
+
node.total += node.dirs[k].total;
|
|
24658
|
+
node.covered += node.dirs[k].covered;
|
|
24659
|
+
});
|
|
24660
|
+
}
|
|
24661
|
+
|
|
24662
|
+
function covDefaultOpen(model) {
|
|
24663
|
+
// Root and its first level open by default; deeper stays folded until asked.
|
|
24664
|
+
var open = Object.create(null);
|
|
24665
|
+
open[""] = true;
|
|
24666
|
+
Object.keys(model.root.dirs).forEach(function (k) { open[k] = true; });
|
|
24667
|
+
return open;
|
|
24668
|
+
}
|
|
24669
|
+
|
|
24670
|
+
function covFileVisible(f) {
|
|
24671
|
+
if (covState.unc && f.cases.length > 0) return false;
|
|
24672
|
+
if (covState.q && f.path.toLowerCase().indexOf(covState.q) === -1) return false;
|
|
24673
|
+
return true;
|
|
24674
|
+
}
|
|
24675
|
+
|
|
24676
|
+
function renderCoverage() {
|
|
24677
|
+
var model = covState.model;
|
|
24678
|
+
if (!model) return;
|
|
24679
|
+
if (!covState.openDirs) covState.openDirs = covDefaultOpen(model);
|
|
24680
|
+
|
|
24681
|
+
var uncovered = model.root.total - model.root.covered;
|
|
24682
|
+
var pct = model.root.total ? Math.round((model.root.covered / model.root.total) * 100) : 0;
|
|
24683
|
+
var inv = document.getElementById("cov-inv");
|
|
24684
|
+
clear(inv);
|
|
24685
|
+
inv.appendChild(el("b", null, model.hasUniverse ? pct + "%" : String(model.root.covered)));
|
|
24686
|
+
inv.appendChild(document.createTextNode(
|
|
24687
|
+
model.hasUniverse
|
|
24688
|
+
? " — " + model.root.covered + " / " + model.root.total + " " + t("coverage.files")
|
|
24689
|
+
: " " + t("coverage.files") + " (" + t("coverage.reached") + ")"
|
|
24690
|
+
));
|
|
24691
|
+
var axis = document.getElementById("cov-axis");
|
|
24692
|
+
clear(axis);
|
|
24693
|
+
var segments = [{ cls: "sg-verified", state: "reached", count: model.root.covered }];
|
|
24694
|
+
if (model.hasUniverse) segments.push({ cls: "sg-rerunneeded", state: "uncovered", count: uncovered });
|
|
24695
|
+
if (model.root.total > 0) axis.appendChild(ovAxisRow("", segments, "coverage.", model.root.total));
|
|
24696
|
+
document.getElementById("cov-note").hidden = model.hasUniverse;
|
|
24697
|
+
// Without a denominator every shown file is reached — the filter could
|
|
24698
|
+
// only ever produce an empty tree, so it is withdrawn, not just zeroed.
|
|
24699
|
+
var uncChip = document.getElementById("cov-unc");
|
|
24700
|
+
uncChip.hidden = !model.hasUniverse;
|
|
24701
|
+
if (!model.hasUniverse && covState.unc) {
|
|
24702
|
+
covState.unc = false;
|
|
24703
|
+
uncChip.setAttribute("aria-pressed", "false");
|
|
24704
|
+
}
|
|
24705
|
+
document.getElementById("cov-unc-n").textContent = model.hasUniverse ? String(uncovered) : "";
|
|
24706
|
+
|
|
24707
|
+
var asof = document.getElementById("cov-asof");
|
|
24708
|
+
var head = model.report.git && model.report.git.head ? String(model.report.git.head).slice(0, 7) : null;
|
|
24709
|
+
asof.textContent = t("coverage.measured") + ": " + relTime(model.report.createdAt) +
|
|
24710
|
+
(head ? " (" + head + ")" : "") + " - " + model.measuredSpecs + " " + t("coverage.specsCombined");
|
|
24711
|
+
|
|
24712
|
+
// The tree is rebuilt from scratch, which would otherwise snap the pane
|
|
24713
|
+
// back to the top on every toggle deep in the hierarchy.
|
|
24714
|
+
var wrap = document.querySelector(".cov-treewrap");
|
|
24715
|
+
var scroll = wrap ? wrap.scrollTop : 0;
|
|
24716
|
+
var host = document.getElementById("cov-tree");
|
|
24717
|
+
clear(host);
|
|
24718
|
+
var ul = document.createElement("ul");
|
|
24719
|
+
var rootLi = covRenderDir(model.root, "");
|
|
24720
|
+
document.getElementById("cov-no-hit").hidden = !!rootLi;
|
|
24721
|
+
if (rootLi) ul.appendChild(rootLi);
|
|
24722
|
+
host.appendChild(ul);
|
|
24723
|
+
if (wrap) wrap.scrollTop = scroll;
|
|
24724
|
+
|
|
24725
|
+
// The detail pane re-renders with the tree: language toggles, refreshes
|
|
24726
|
+
// and filters would otherwise leave it describing the previous state.
|
|
24727
|
+
var sel = covState.selected ? model.fileByPath[covState.selected] : null;
|
|
24728
|
+
if (sel && covFileVisible(sel)) {
|
|
24729
|
+
covShowDetail(sel);
|
|
24730
|
+
} else {
|
|
24731
|
+
covState.selected = null;
|
|
24732
|
+
var detail = document.getElementById("cov-detail");
|
|
24733
|
+
clear(detail);
|
|
24734
|
+
detail.appendChild(el("div", "ph", t("coverage.placeholder")));
|
|
24735
|
+
}
|
|
24736
|
+
}
|
|
24737
|
+
|
|
24738
|
+
function covRenderDir(node, path) {
|
|
24739
|
+
var filtering = covState.unc || !!covState.q;
|
|
24740
|
+
var isOpen = filtering || !!covState.openDirs[path];
|
|
24741
|
+
// A closed, unfiltered directory renders as a single row \u2014 its counts come
|
|
24742
|
+
// from covAnnotate \u2014 so the subtree is not walked at all. At the 20k-file
|
|
24743
|
+
// ceiling that walk is the whole render cost.
|
|
24744
|
+
var subs = [];
|
|
24745
|
+
var visFiles = [];
|
|
24746
|
+
if (isOpen) {
|
|
24747
|
+
visFiles = node.files.filter(covFileVisible);
|
|
24748
|
+
Object.keys(node.dirs).sort().forEach(function (k) {
|
|
24749
|
+
var sub = covRenderDir(node.dirs[k], path ? path + "/" + k : k);
|
|
24750
|
+
if (sub) subs.push(sub);
|
|
24751
|
+
});
|
|
24752
|
+
if (filtering && visFiles.length === 0 && subs.length === 0) return null;
|
|
24753
|
+
}
|
|
24754
|
+
var li = document.createElement("li");
|
|
24755
|
+
if (path !== "") {
|
|
24756
|
+
var row = el("button", "cov-row");
|
|
24757
|
+
row.type = "button";
|
|
24758
|
+
var chev = chevron();
|
|
24759
|
+
if (isOpen) chev.classList.add("open");
|
|
24760
|
+
row.appendChild(chev);
|
|
24761
|
+
row.appendChild(covIcon(true));
|
|
24762
|
+
row.appendChild(el("span", "nm", node.name + "/"));
|
|
24763
|
+
row.appendChild(el("span", "sp"));
|
|
24764
|
+
var mini = el("span", "minibar");
|
|
24765
|
+
var fill = el("i");
|
|
24766
|
+
fill.style.width = (node.total ? Math.round((node.covered / node.total) * 100) : 0) + "%";
|
|
24767
|
+
mini.appendChild(fill);
|
|
24768
|
+
row.appendChild(mini);
|
|
24769
|
+
row.appendChild(el("span", "frac", node.covered + "/" + node.total));
|
|
24770
|
+
row.addEventListener("click", function () {
|
|
24771
|
+
if (covState.openDirs[path]) delete covState.openDirs[path];
|
|
24772
|
+
else covState.openDirs[path] = true;
|
|
24773
|
+
renderCoverage();
|
|
24774
|
+
});
|
|
24775
|
+
li.appendChild(row);
|
|
24776
|
+
}
|
|
24777
|
+
if (isOpen) {
|
|
24778
|
+
var ul = document.createElement("ul");
|
|
24779
|
+
if (path === "") ul.style.marginLeft = "0";
|
|
24780
|
+
subs.forEach(function (sub) { ul.appendChild(sub); });
|
|
24781
|
+
visFiles.forEach(function (f) { ul.appendChild(covRenderFile(f)); });
|
|
24782
|
+
li.appendChild(ul);
|
|
24783
|
+
}
|
|
24784
|
+
return li;
|
|
24785
|
+
}
|
|
24786
|
+
|
|
24787
|
+
function covRenderFile(f) {
|
|
24788
|
+
var li = document.createElement("li");
|
|
24789
|
+
var row = el("button", "cov-row" + (covState.selected === f.path ? " sel" : ""));
|
|
24790
|
+
row.type = "button";
|
|
24791
|
+
row.appendChild(el("span", "chev"));
|
|
24792
|
+
row.appendChild(covIcon(false));
|
|
24793
|
+
row.appendChild(el("span", "nm" + (f.cases.length > 0 ? " dim" : ""), f.name));
|
|
24794
|
+
row.appendChild(el("span", "sp"));
|
|
24795
|
+
row.appendChild(el("span", "dot " + (f.cases.length > 0 ? "ok" : "no")));
|
|
24796
|
+
row.appendChild(el("span", "frac", f.cases.length > 0
|
|
24797
|
+
? f.cases.length + " " + (f.cases.length === 1 ? t("coverage.case") : t("coverage.cases"))
|
|
24798
|
+
: "0"));
|
|
24799
|
+
row.addEventListener("click", function () {
|
|
24800
|
+
covState.selected = f.path;
|
|
24801
|
+
renderCoverage();
|
|
24802
|
+
});
|
|
24803
|
+
li.appendChild(row);
|
|
24804
|
+
return li;
|
|
24805
|
+
}
|
|
24806
|
+
|
|
24807
|
+
function covIcon(isDir) {
|
|
24808
|
+
var svg = svgIcon();
|
|
24809
|
+
svg.setAttribute("class", "ic");
|
|
24810
|
+
svg.appendChild(svgPath(isDir
|
|
24811
|
+
? "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
|
24812
|
+
: "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"));
|
|
24813
|
+
return svg;
|
|
24814
|
+
}
|
|
24815
|
+
|
|
24816
|
+
function covShowDetail(f) {
|
|
24817
|
+
var host = document.getElementById("cov-detail");
|
|
24818
|
+
clear(host);
|
|
24819
|
+
var title = el("h4", null, f.path);
|
|
24820
|
+
host.appendChild(title);
|
|
24821
|
+
if (f.cases.length === 0) {
|
|
24822
|
+
var meta = el("div", "meta");
|
|
24823
|
+
var badge = el("span", "badge uncov");
|
|
24824
|
+
badge.appendChild(el("span", "d"));
|
|
24825
|
+
badge.appendChild(document.createTextNode(" " + t("coverage.uncovered")));
|
|
24826
|
+
meta.appendChild(badge);
|
|
24827
|
+
host.appendChild(meta);
|
|
24828
|
+
host.appendChild(el("div", "cov-hint", t("coverage.fileUncovered")));
|
|
24829
|
+
return;
|
|
24830
|
+
}
|
|
24831
|
+
host.appendChild(el("div", "meta", f.cases.length + " " + t("coverage.casesReach")));
|
|
24832
|
+
var list = el("div", "cov-caselist");
|
|
24833
|
+
var runId = covState.model.run.id;
|
|
24834
|
+
// No pass/fail here on purpose: this pane answers "what reaches this
|
|
24835
|
+
// file", not "did it pass" — the run page holds the verdicts.
|
|
24836
|
+
f.cases.forEach(function (key) {
|
|
24837
|
+
var a = document.createElement("a");
|
|
24838
|
+
a.href = "#/runs/" + encodeURIComponent(runId);
|
|
24839
|
+
a.appendChild(el("span", "cs", key));
|
|
24840
|
+
list.appendChild(a);
|
|
24841
|
+
});
|
|
24842
|
+
host.appendChild(list);
|
|
24843
|
+
}
|
|
24844
|
+
|
|
22330
24845
|
// Entering the view or refreshing it. The spend readout is a fixed window,
|
|
22331
24846
|
// so a filter change reloads the list alone.
|
|
22332
24847
|
function loadRuns() {
|
|
@@ -22656,6 +25171,75 @@ const CLIENT_JS = `
|
|
|
22656
25171
|
return wrap;
|
|
22657
25172
|
}
|
|
22658
25173
|
|
|
25174
|
+
// ── run detail: reached files (--coverage) ─────────────────────────
|
|
25175
|
+
//
|
|
25176
|
+
// The gap counters sit above the list on purpose. Every one of them is an
|
|
25177
|
+
// execution the measurement could not place, and an unplaced execution reads
|
|
25178
|
+
// as "never reached" — the exact answer this section exists to give — so they
|
|
25179
|
+
// belong next to the answer rather than in a log nobody opens.
|
|
25180
|
+
function coverageSection(cov) {
|
|
25181
|
+
var wrap = el("div");
|
|
25182
|
+
wrap.appendChild(el("div", "assertions-hint muted", t("acc.coverage.hint")));
|
|
25183
|
+
var gaps = cov.gaps || {};
|
|
25184
|
+
var counts = el("div", "cov-counts");
|
|
25185
|
+
[
|
|
25186
|
+
{ label: t("cov.frontend"), value: cov.frontendFiles, always: true },
|
|
25187
|
+
{ label: t("cov.backend"), value: cov.backendFiles, always: true },
|
|
25188
|
+
].forEach(function (count) {
|
|
25189
|
+
var chip = el("span", "cov-count");
|
|
25190
|
+
chip.appendChild(el("b", null, String(count.value || 0)));
|
|
25191
|
+
chip.appendChild(document.createTextNode(" " + count.label));
|
|
25192
|
+
counts.appendChild(chip);
|
|
25193
|
+
});
|
|
25194
|
+
// Which route the attribution came by. The carrier is always live; a
|
|
25195
|
+
// declared identity is shown even at zero events, because a window that
|
|
25196
|
+
// matched nothing is the failure and an omitted chip would hide it.
|
|
25197
|
+
counts.appendChild(el("span", "cov-count muted", t("cov.route.carrier")));
|
|
25198
|
+
(cov.actorWindows || []).forEach(function (w) {
|
|
25199
|
+
var chip = el("span", w.events ? "cov-count" : "cov-count cov-warn");
|
|
25200
|
+
chip.appendChild(document.createTextNode(t("cov.route.actorWindow") + "(" + w.key + ") "));
|
|
25201
|
+
chip.appendChild(el("b", null, String(w.events || 0)));
|
|
25202
|
+
chip.appendChild(document.createTextNode(" " + t("cov.route.events")));
|
|
25203
|
+
counts.appendChild(chip);
|
|
25204
|
+
});
|
|
25205
|
+
// "That half never answered" and "that half reached nothing" render as the
|
|
25206
|
+
// same zero, so the first is said in words.
|
|
25207
|
+
[
|
|
25208
|
+
{ shown: cov.backendReported === false, label: t("cov.noBackend") },
|
|
25209
|
+
{ shown: cov.frontendReported === false, label: t("cov.noFrontend") },
|
|
25210
|
+
{ shown: cov.frontendStopped === true, label: t("cov.frontendStopped") },
|
|
25211
|
+
].forEach(function (flag) {
|
|
25212
|
+
if (!flag.shown) return;
|
|
25213
|
+
counts.appendChild(el("span", "cov-count cov-warn", flag.label));
|
|
25214
|
+
});
|
|
25215
|
+
[
|
|
25216
|
+
"unattributed", "unmappedScripts", "unmappedRanges",
|
|
25217
|
+
"outsideProject", "unresolvedSources", "uninstrumentedFiles",
|
|
25218
|
+
"uninstrumentedProcesses", "droppedPushes",
|
|
25219
|
+
"unmappedActorEvents", "outsideWindowEvents",
|
|
25220
|
+
].forEach(function (key) {
|
|
25221
|
+
if (!gaps[key]) return;
|
|
25222
|
+
var chip = el("span", "cov-count");
|
|
25223
|
+
chip.appendChild(el("b", null, String(gaps[key])));
|
|
25224
|
+
chip.appendChild(document.createTextNode(" " + t("cov." + key)));
|
|
25225
|
+
counts.appendChild(chip);
|
|
25226
|
+
});
|
|
25227
|
+
// Last, and outside the gap list: excluded dependencies are not a hole in
|
|
25228
|
+
// the measurement, and next to ones that are they would drown them out.
|
|
25229
|
+
if (cov.excludedDependencies) {
|
|
25230
|
+
var excluded = el("span", "cov-count muted");
|
|
25231
|
+
excluded.appendChild(el("b", null, String(cov.excludedDependencies)));
|
|
25232
|
+
excluded.appendChild(document.createTextNode(" " + t("cov.excludedDependencies")));
|
|
25233
|
+
counts.appendChild(excluded);
|
|
25234
|
+
}
|
|
25235
|
+
wrap.appendChild(counts);
|
|
25236
|
+
// One insertion for a list that can run to thousands of rows.
|
|
25237
|
+
var files = document.createDocumentFragment();
|
|
25238
|
+
(cov.files || []).forEach(function (f) { files.appendChild(el("div", "cov-file", f)); });
|
|
25239
|
+
wrap.appendChild(files);
|
|
25240
|
+
return wrap;
|
|
25241
|
+
}
|
|
25242
|
+
|
|
22659
25243
|
// The parts a live step and a deterministic step render identically: the
|
|
22660
25244
|
// status-railed card, a header (#index + instruction + a status badge unless
|
|
22661
25245
|
// passed), and an optional "expects:"/reasoning meta block. Returns { card,
|
|
@@ -22979,6 +25563,18 @@ const CLIENT_JS = `
|
|
|
22979
25563
|
any = true;
|
|
22980
25564
|
}
|
|
22981
25565
|
|
|
25566
|
+
if (r.coverage) {
|
|
25567
|
+
var covFiles = r.coverage.files || [];
|
|
25568
|
+
body.appendChild(detailsBlock(t("acc.coverage"), covFiles.length, coverageSection(r.coverage)));
|
|
25569
|
+
any = true;
|
|
25570
|
+
} else if (r.coverageUnavailable) {
|
|
25571
|
+
// The run measured coverage but this spec could not be: say so, rather
|
|
25572
|
+
// than leave a row that looks like it reached nothing.
|
|
25573
|
+
body.appendChild(el("div", "section-label", t("acc.coverage")));
|
|
25574
|
+
body.appendChild(el("div", "muted", t("cov.unavailable") + " " + r.coverageUnavailable));
|
|
25575
|
+
any = true;
|
|
25576
|
+
}
|
|
25577
|
+
|
|
22982
25578
|
if (any) card.appendChild(body);
|
|
22983
25579
|
return card;
|
|
22984
25580
|
}
|
|
@@ -25218,6 +27814,15 @@ const CLIENT_JS = `
|
|
|
25218
27814
|
// ── project switching ───────────────────────────────────────────────
|
|
25219
27815
|
|
|
25220
27816
|
function setProject(p) {
|
|
27817
|
+
if (p !== state.project) {
|
|
27818
|
+
// The coverage page caches its model per project; a stale one would
|
|
27819
|
+
// draw the previous project's tree under the new project's header.
|
|
27820
|
+
// Only on a real switch: setLang() calls this with the same project
|
|
27821
|
+
// just to refresh labels.
|
|
27822
|
+
covState.model = null;
|
|
27823
|
+
covState.selected = null;
|
|
27824
|
+
covState.openDirs = null;
|
|
27825
|
+
}
|
|
25221
27826
|
state.project = p;
|
|
25222
27827
|
document.getElementById("project-current").textContent = p || "none";
|
|
25223
27828
|
document.getElementById("sidebar-project").textContent = p || t("app.noProject");
|
|
@@ -25561,6 +28166,27 @@ const CLIENT_JS = `
|
|
|
25561
28166
|
perspState.q = e.target.value.trim().toLowerCase();
|
|
25562
28167
|
renderPerspectives();
|
|
25563
28168
|
});
|
|
28169
|
+
|
|
28170
|
+
// Debounced, unlike persp-q: each keystroke rebuilds the whole tree, and
|
|
28171
|
+
// the universe can hold thousands of files.
|
|
28172
|
+
var covQTimer = null;
|
|
28173
|
+
document.getElementById("cov-q").addEventListener("input", function (e) {
|
|
28174
|
+
var value = e.target.value.trim().toLowerCase();
|
|
28175
|
+
clearTimeout(covQTimer);
|
|
28176
|
+
covQTimer = setTimeout(function () {
|
|
28177
|
+
covState.q = value;
|
|
28178
|
+
renderCoverage();
|
|
28179
|
+
}, 150);
|
|
28180
|
+
});
|
|
28181
|
+
document.getElementById("cov-unc").addEventListener("click", function () {
|
|
28182
|
+
covState.unc = !covState.unc;
|
|
28183
|
+
document.getElementById("cov-unc").setAttribute("aria-pressed", String(covState.unc));
|
|
28184
|
+
renderCoverage();
|
|
28185
|
+
});
|
|
28186
|
+
document.getElementById("cov-refresh").addEventListener("click", function () {
|
|
28187
|
+
covState.model = null;
|
|
28188
|
+
loadCoverage();
|
|
28189
|
+
});
|
|
25564
28190
|
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
25565
28191
|
b.addEventListener("click", function () {
|
|
25566
28192
|
// renderPerspectives -> syncPerspChips repaints aria-pressed from
|