ccqa 1.41.0 → 1.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/ccqa.mjs +267 -145
- package/dist/hub-client/index.d.mts +27 -0
- package/dist/hub-client/index.mjs +15 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -5661,6 +5661,24 @@ function actorGroups(plan) {
|
|
|
5661
5661
|
return (ref) => (plan.windowsForSpec.get(specKey(ref)) ?? []).map((window) => window.key);
|
|
5662
5662
|
}
|
|
5663
5663
|
//#endregion
|
|
5664
|
+
//#region src/coverage/spec-id.ts
|
|
5665
|
+
/**
|
|
5666
|
+
* `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
|
|
5667
|
+
* run out; the spec half is `specKey`, so an id here and a report row name the
|
|
5668
|
+
* same spec the same way.
|
|
5669
|
+
*/
|
|
5670
|
+
function specIdFor(runId, ref) {
|
|
5671
|
+
return `${runId}.${specKey(ref)}`;
|
|
5672
|
+
}
|
|
5673
|
+
/**
|
|
5674
|
+
* Inverse of `specIdFor`: the `feature/spec` half, or null when the id was
|
|
5675
|
+
* minted under another run. The runId itself may contain `.`, so the known
|
|
5676
|
+
* prefix is stripped by length, never by splitting on the dot.
|
|
5677
|
+
*/
|
|
5678
|
+
function specKeyFromSpecId(specId, runId) {
|
|
5679
|
+
return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
|
|
5680
|
+
}
|
|
5681
|
+
//#endregion
|
|
5664
5682
|
//#region src/coverage/contract.ts
|
|
5665
5683
|
/**
|
|
5666
5684
|
* The names and shapes ccqa agrees on with the instrumented application.
|
|
@@ -7905,14 +7923,6 @@ async function resolveRoot(cwd, declared) {
|
|
|
7905
7923
|
function specCoverageDir(reportDir, feature, spec) {
|
|
7906
7924
|
return join(reportDir, "coverage", feature, spec);
|
|
7907
7925
|
}
|
|
7908
|
-
/**
|
|
7909
|
-
* `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
|
|
7910
|
-
* run out; the spec half is `specKey`, so an id here and a report row name the
|
|
7911
|
-
* same spec the same way.
|
|
7912
|
-
*/
|
|
7913
|
-
function specIdFor(runId, ref) {
|
|
7914
|
-
return `${runId}.${specKey(ref)}`;
|
|
7915
|
-
}
|
|
7916
7926
|
async function readFrontend(coverageDir, specId) {
|
|
7917
7927
|
let raw;
|
|
7918
7928
|
try {
|
|
@@ -10321,6 +10331,28 @@ z.object({
|
|
|
10321
10331
|
totalUsd: z.number(),
|
|
10322
10332
|
entries: z.array(SpendEntrySchema)
|
|
10323
10333
|
});
|
|
10334
|
+
/**
|
|
10335
|
+
* The coverage-edge ledger: per spec, the most recent measured reach — the
|
|
10336
|
+
* input measured spec selection intersects a diff with (ADR-0026). One
|
|
10337
|
+
* document per project; entries never expire, they are only replaced by a
|
|
10338
|
+
* newer measurement. `measuredAt` is stamped by the hub on merge, so entries
|
|
10339
|
+
* written by different runs stay comparable on one clock.
|
|
10340
|
+
*/
|
|
10341
|
+
const CoverageEdgeEntrySchema = z.object({
|
|
10342
|
+
files: z.array(z.string()),
|
|
10343
|
+
measuredAt: z.number(),
|
|
10344
|
+
runId: z.string().optional()
|
|
10345
|
+
});
|
|
10346
|
+
const CoverageEdgesDocSchema = z.object({ specs: z.record(z.string(), CoverageEdgeEntrySchema) });
|
|
10347
|
+
/**
|
|
10348
|
+
* Body of `PUT /projects/:project/coverage-edges` — the specs one run
|
|
10349
|
+
* measured. Merged into the stored document, never replacing other specs'
|
|
10350
|
+
* entries; an empty file set is not a measurement and is rejected per entry.
|
|
10351
|
+
*/
|
|
10352
|
+
const CoverageEdgesUpsertSchema = z.object({ specs: z.record(z.string(), z.object({
|
|
10353
|
+
files: z.array(z.string()).min(1),
|
|
10354
|
+
runId: z.string().optional()
|
|
10355
|
+
})).refine((specs) => Object.keys(specs).length > 0, "at least one spec entry is required") });
|
|
10324
10356
|
//#endregion
|
|
10325
10357
|
//#region src/run/hub-selection.ts
|
|
10326
10358
|
/**
|
|
@@ -11719,8 +11751,9 @@ function enrichZodError(error, source) {
|
|
|
11719
11751
|
* means that spec must re-run — reach cannot see the test's own definition,
|
|
11720
11752
|
* so no measurement is consulted for it. Everything else intersects the diff
|
|
11721
11753
|
* with the files the spec's last measured run actually reached (ADR-0024);
|
|
11722
|
-
* a spec with no measurement
|
|
11723
|
-
*
|
|
11754
|
+
* a spec with no measurement is `needed` — it runs until a measurement
|
|
11755
|
+
* lands, which is also what records its first edge (ADR-0026). Only when
|
|
11756
|
+
* the measurements could not be read does absence degrade to `unknown`.
|
|
11724
11757
|
*/
|
|
11725
11758
|
async function selectSpecs(input) {
|
|
11726
11759
|
const { changed, specs, cwd, base, head, edges } = input;
|
|
@@ -11815,27 +11848,37 @@ function isCcqaPath(path) {
|
|
|
11815
11848
|
/**
|
|
11816
11849
|
* Hold each undecided spec's last measured reach against the diff.
|
|
11817
11850
|
*
|
|
11818
|
-
* Three outcomes, and only
|
|
11819
|
-
*
|
|
11820
|
-
* the
|
|
11821
|
-
*
|
|
11822
|
-
* `
|
|
11823
|
-
*
|
|
11851
|
+
* Three outcomes, and only one is a positive claim: a non-empty intersection
|
|
11852
|
+
* means `needed`, with the intersecting paths as the reason; an empty one
|
|
11853
|
+
* means `notNeeded` — the measurement accounts for everything the spec
|
|
11854
|
+
* reached, and the diff missed all of it; no measurement at all is also
|
|
11855
|
+
* `needed` — the spec runs until a measurement records its reach (ADR-0026)
|
|
11856
|
+
* — unless the read was degraded, in which case absence proves nothing and
|
|
11857
|
+
* the spec is left `unknown`. Changes outside the measured root fall out
|
|
11824
11858
|
* of the comparison entirely: the root is the declared boundary of what
|
|
11825
11859
|
* measurement governs, so what lies beyond it clears specs quietly — one
|
|
11826
11860
|
* warning names the dropped paths, because a root configured too narrow
|
|
11827
11861
|
* looks exactly like this and hides real reach (see docs/coverage.md).
|
|
11828
11862
|
*/
|
|
11829
11863
|
async function judgeWithCoverage(input) {
|
|
11830
|
-
const { pending, productChanges, cwd
|
|
11831
|
-
const
|
|
11832
|
-
|
|
11864
|
+
const { pending, productChanges, cwd } = input;
|
|
11865
|
+
const { edges, degraded } = input.edges;
|
|
11866
|
+
const unreadable = "the hub's measured reach could not be read; not guessing";
|
|
11833
11867
|
const measuredChanges = rerootChangesForCoverage(productChanges, await resolveCoverageRoots(productChanges, cwd));
|
|
11834
11868
|
const dropped = productChanges.length - measuredChanges.length;
|
|
11835
11869
|
if (dropped > 0) warn(`select-specs: ${dropped} of ${productChanges.length} changed files fall outside coverage.projectRoot and cannot be compared against measured reach`);
|
|
11836
11870
|
return pending.map((spec) => {
|
|
11837
11871
|
const edge = edges.get(specKey(spec));
|
|
11838
|
-
if (!edge)
|
|
11872
|
+
if (!edge) {
|
|
11873
|
+
if (degraded) return unknownSelection(spec, unreadable);
|
|
11874
|
+
return {
|
|
11875
|
+
featureName: spec.featureName,
|
|
11876
|
+
specName: spec.specName,
|
|
11877
|
+
verdict: "needed",
|
|
11878
|
+
source: "coverage",
|
|
11879
|
+
reason: "never measured: the spec runs until a measurement records its reach"
|
|
11880
|
+
};
|
|
11881
|
+
}
|
|
11839
11882
|
const touchedBy = measuredChanges.filter((c) => edge.files.has(c.measured)).map((c) => c.original);
|
|
11840
11883
|
if (touchedBy.length > 0) return {
|
|
11841
11884
|
featureName: spec.featureName,
|
|
@@ -11911,46 +11954,74 @@ async function resolveCoverageRoots(changed, cwd) {
|
|
|
11911
11954
|
//#endregion
|
|
11912
11955
|
//#region src/select/coverage-edges.ts
|
|
11913
11956
|
/**
|
|
11914
|
-
* How many recent hub runs are probed for report-row coverage.
|
|
11915
|
-
* because every probe downloads a whole report.json;
|
|
11916
|
-
*
|
|
11917
|
-
* spec runs — is the safer answer anyway. The stream side carries its own
|
|
11918
|
-
* bound: the hub lists at most its newest twenty measured runs.
|
|
11957
|
+
* How many recent hub runs are probed for legacy report-row coverage.
|
|
11958
|
+
* Bounded because every probe downloads a whole report.json; anything this
|
|
11959
|
+
* far back has usually been superseded by the ledger anyway.
|
|
11919
11960
|
*/
|
|
11920
11961
|
const MAX_REPORT_RUNS = 20;
|
|
11921
11962
|
/**
|
|
11922
|
-
*
|
|
11923
|
-
*
|
|
11924
|
-
*
|
|
11925
|
-
*
|
|
11926
|
-
*/
|
|
11927
|
-
const EDGE_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
|
|
11928
|
-
/**
|
|
11929
|
-
* Read every spec's most recent measured reach from the hub.
|
|
11930
|
-
*
|
|
11931
|
-
* Never throws: a hub that cannot be read yields an empty map (warned), which
|
|
11932
|
-
* the selection degrades to `unknown` across the board — the caller runs
|
|
11933
|
-
* those specs, so an unreadable hub costs runs, never a skipped regression.
|
|
11963
|
+
* Read every spec's most recent measured reach from the hub. Never throws: a
|
|
11964
|
+
* source that cannot be read warns, and `degraded` flips when the failure
|
|
11965
|
+
* leaves absence ambiguous (the ledger itself, or the legacy sources while
|
|
11966
|
+
* no ledger answers).
|
|
11934
11967
|
*/
|
|
11935
11968
|
async function loadCoverageEdges(input) {
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11969
|
+
if (input == null) return {
|
|
11970
|
+
edges: /* @__PURE__ */ new Map(),
|
|
11971
|
+
degraded: true
|
|
11972
|
+
};
|
|
11973
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
11974
|
+
const merge = (key, candidate) => {
|
|
11975
|
+
const existing = candidates.get(key);
|
|
11976
|
+
if (!existing || candidate.measuredAt > existing.measuredAt) candidates.set(key, candidate);
|
|
11977
|
+
};
|
|
11978
|
+
const [ledger, ...legacy] = await Promise.allSettled([
|
|
11979
|
+
collectLedgerEdges(input, merge),
|
|
11980
|
+
collectStreamEdges(input, merge),
|
|
11981
|
+
collectReportEdges(input, merge)
|
|
11982
|
+
]);
|
|
11944
11983
|
let skipped = 0;
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11984
|
+
let legacyBroken = false;
|
|
11985
|
+
for (const result of legacy) if (result.status === "rejected") {
|
|
11986
|
+
legacyBroken = true;
|
|
11987
|
+
warn(`select-specs: could not read coverage measurements from the hub (${errMessage(result.reason)})`);
|
|
11988
|
+
} else skipped += result.value;
|
|
11989
|
+
if (skipped > 0) {
|
|
11990
|
+
legacyBroken = true;
|
|
11991
|
+
warn(`select-specs: ${skipped} measured run(s) on the hub could not be read; treating the measurements as unreadable rather than absent`);
|
|
11992
|
+
}
|
|
11993
|
+
if (ledger.status === "rejected") warn(`select-specs: could not read the hub's coverage-edge ledger (${errMessage(ledger.reason)})`);
|
|
11994
|
+
const ledgerAnswered = ledger.status === "fulfilled" && ledger.value;
|
|
11995
|
+
const degraded = ledger.status === "rejected" || !ledgerAnswered && legacyBroken;
|
|
11996
|
+
return {
|
|
11997
|
+
edges: new Map([...candidates].map(([key, c]) => [key, {
|
|
11998
|
+
files: new Set(c.files),
|
|
11999
|
+
measuredAt: c.measuredAt
|
|
12000
|
+
}])),
|
|
12001
|
+
degraded
|
|
12002
|
+
};
|
|
12003
|
+
}
|
|
12004
|
+
/**
|
|
12005
|
+
* The ledger itself; true when a document answered. A 404 is an older hub or
|
|
12006
|
+
* a project that never wrote one, not a failure: the legacy sources answer.
|
|
12007
|
+
*/
|
|
12008
|
+
async function collectLedgerEdges(input, merge) {
|
|
12009
|
+
const doc = await input.hub.getCoverageEdges(input.project);
|
|
12010
|
+
if (doc === null) return false;
|
|
12011
|
+
for (const [key, entry] of Object.entries(doc.specs)) {
|
|
12012
|
+
if (entry.files.length === 0) continue;
|
|
12013
|
+
merge(key, {
|
|
12014
|
+
files: entry.files,
|
|
12015
|
+
measuredAt: entry.measuredAt
|
|
12016
|
+
});
|
|
12017
|
+
}
|
|
12018
|
+
return true;
|
|
11949
12019
|
}
|
|
11950
12020
|
/**
|
|
11951
|
-
*
|
|
11952
|
-
* recently measured run and lists every run the stream retains;
|
|
11953
|
-
* run is then resolved individually. Returns how many runs could
|
|
12021
|
+
* Legacy: edges from the coverage event stream. The plain read answers for
|
|
12022
|
+
* the most recently measured run and lists every run the stream retains;
|
|
12023
|
+
* each older run is then resolved individually. Returns how many runs could
|
|
12024
|
+
* not be read.
|
|
11954
12025
|
*/
|
|
11955
12026
|
async function collectStreamEdges(input, merge) {
|
|
11956
12027
|
const { hub, project } = input;
|
|
@@ -11962,24 +12033,16 @@ async function collectStreamEdges(input, merge) {
|
|
|
11962
12033
|
function ingestResolved(resolved, merge) {
|
|
11963
12034
|
if (!resolved) return;
|
|
11964
12035
|
for (const spec of resolved.specs) {
|
|
11965
|
-
const key =
|
|
12036
|
+
const key = specKeyFromSpecId(spec.specId, resolved.runId);
|
|
11966
12037
|
if (key === null) continue;
|
|
11967
12038
|
if (spec.files.length === 0) continue;
|
|
11968
12039
|
merge(key, {
|
|
11969
|
-
files:
|
|
12040
|
+
files: spec.files,
|
|
11970
12041
|
measuredAt: resolved.asOf
|
|
11971
12042
|
});
|
|
11972
12043
|
}
|
|
11973
12044
|
}
|
|
11974
12045
|
/**
|
|
11975
|
-
* A stream specId is `<runId>.<feature>/<spec>` (src/coverage/session.ts).
|
|
11976
|
-
* The runId itself may contain `.`, so the known prefix is stripped by
|
|
11977
|
-
* length, never by splitting on the dot.
|
|
11978
|
-
*/
|
|
11979
|
-
function stripRunIdPrefix(specId, runId) {
|
|
11980
|
-
return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
|
|
11981
|
-
}
|
|
11982
|
-
/**
|
|
11983
12046
|
* The one slice of report.json this consumer reads. Parsed with its own
|
|
11984
12047
|
* narrow schema rather than the full report schema so a report from another
|
|
11985
12048
|
* ccqa version still yields its edges as long as this shape holds.
|
|
@@ -11990,10 +12053,10 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
|
|
|
11990
12053
|
coverage: z.object({ files: z.array(z.string()) }).optional()
|
|
11991
12054
|
})) });
|
|
11992
12055
|
/**
|
|
11993
|
-
*
|
|
11994
|
-
* probed — audits and recordings execute no specs
|
|
11995
|
-
*
|
|
11996
|
-
*
|
|
12056
|
+
* Legacy: edges from pushed run reports, newest first. Only `kind: run` runs
|
|
12057
|
+
* are probed — audits and recordings execute no specs — and a still-`running`
|
|
12058
|
+
* run is skipped: its rows are still arriving. Returns how many reports
|
|
12059
|
+
* could not be read.
|
|
11997
12060
|
*/
|
|
11998
12061
|
async function collectReportEdges(input, merge) {
|
|
11999
12062
|
const { hub, project } = input;
|
|
@@ -12016,7 +12079,7 @@ async function collectReportEdges(input, merge) {
|
|
|
12016
12079
|
for (const row of parsed.data.results) {
|
|
12017
12080
|
if (!row.coverage || row.coverage.files.length === 0) continue;
|
|
12018
12081
|
merge(`${row.feature}/${row.spec}`, {
|
|
12019
|
-
files:
|
|
12082
|
+
files: row.coverage.files,
|
|
12020
12083
|
measuredAt
|
|
12021
12084
|
});
|
|
12022
12085
|
}
|
|
@@ -15641,7 +15704,7 @@ const CONFIG_NAMES = [
|
|
|
15641
15704
|
"playwright.config.cjs"
|
|
15642
15705
|
];
|
|
15643
15706
|
async function acquirePlaywrightBrowser(ctx) {
|
|
15644
|
-
await
|
|
15707
|
+
await sweepStaleWrappersOnce(ctx.cwd);
|
|
15645
15708
|
const chromium = await resolveChromium(ctx.cwd);
|
|
15646
15709
|
const port = await freePort();
|
|
15647
15710
|
const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
|
|
@@ -15672,9 +15735,26 @@ async function acquirePlaywrightBrowser(ctx) {
|
|
|
15672
15735
|
};
|
|
15673
15736
|
}
|
|
15674
15737
|
/**
|
|
15675
|
-
* Wrappers a killed earlier run left behind. Deleted
|
|
15676
|
-
* only guarded against: a stray one is git-status
|
|
15738
|
+
* Wrappers a killed earlier run left behind. Deleted before this process
|
|
15739
|
+
* writes its first one, not only guarded against: a stray one is git-status
|
|
15740
|
+
* dirt in somebody's repo.
|
|
15741
|
+
*
|
|
15742
|
+
* Once per cwd per process, never per acquire: every live wrapper matches
|
|
15743
|
+
* the stale pattern, so under `--concurrency` a per-acquire sweep deletes
|
|
15744
|
+
* the wrapper a parallel spec just wrote and is about to hand to
|
|
15745
|
+
* `playwright test`, killing that spec before it starts. The memo makes
|
|
15746
|
+
* concurrent first acquires share one sweep that finished before either of
|
|
15747
|
+
* them wrote anything.
|
|
15677
15748
|
*/
|
|
15749
|
+
const sweptCwds = /* @__PURE__ */ new Map();
|
|
15750
|
+
function sweepStaleWrappersOnce(cwd) {
|
|
15751
|
+
let sweep = sweptCwds.get(cwd);
|
|
15752
|
+
if (!sweep) {
|
|
15753
|
+
sweep = sweepStaleWrappers(cwd);
|
|
15754
|
+
sweptCwds.set(cwd, sweep);
|
|
15755
|
+
}
|
|
15756
|
+
return sweep;
|
|
15757
|
+
}
|
|
15678
15758
|
async function sweepStaleWrappers(cwd) {
|
|
15679
15759
|
const entries = await readdir(cwd).catch(() => []);
|
|
15680
15760
|
for (const name of entries) if (name.startsWith("ccqa-coverage.") && name.endsWith(".playwright.config.ts")) await unlink(join(cwd, name)).catch(() => void 0);
|
|
@@ -16626,22 +16706,21 @@ async function collectChangedSpecs(specs, opts) {
|
|
|
16626
16706
|
specs: [],
|
|
16627
16707
|
base: resolved
|
|
16628
16708
|
};
|
|
16709
|
+
if (!hub) warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
|
|
16710
|
+
const edgesPromise = loadCoverageEdges(hub);
|
|
16629
16711
|
let inventory;
|
|
16630
16712
|
try {
|
|
16631
16713
|
inventory = await loadSpecInventory(cwd);
|
|
16632
16714
|
} catch (e) {
|
|
16633
16715
|
throw new RunUsageError(e.message);
|
|
16634
16716
|
}
|
|
16635
|
-
let edges = /* @__PURE__ */ new Map();
|
|
16636
|
-
if (hub) edges = await loadCoverageEdges(hub);
|
|
16637
|
-
else warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
|
|
16638
16717
|
const report = await selectSpecs({
|
|
16639
16718
|
changed,
|
|
16640
16719
|
specs: inventory,
|
|
16641
16720
|
cwd,
|
|
16642
16721
|
base: resolved.sha,
|
|
16643
16722
|
head: "HEAD",
|
|
16644
|
-
edges
|
|
16723
|
+
edges: await edgesPromise
|
|
16645
16724
|
});
|
|
16646
16725
|
const toRun = new Set(specsToRun(report).map(specKey));
|
|
16647
16726
|
const undecided = report.specs.filter((s) => s.verdict === "unknown").length;
|
|
@@ -16652,53 +16731,6 @@ async function collectChangedSpecs(specs, opts) {
|
|
|
16652
16731
|
};
|
|
16653
16732
|
}
|
|
16654
16733
|
//#endregion
|
|
16655
|
-
//#region src/run/measure-backfill.ts
|
|
16656
|
-
/**
|
|
16657
|
-
* `ccqa run --measure-backfill <n>`: keep the measured-reach edges alive.
|
|
16658
|
-
*
|
|
16659
|
-
* Selection (ADR-0024) consumes each spec's most recent measured reach, and
|
|
16660
|
-
* an edge expires after `EDGE_MAX_AGE_MS`. Nothing else re-measures: an
|
|
16661
|
-
* unmeasured spec answers `unknown`, `unknown` marks nothing due (ADR-0023),
|
|
16662
|
-
* and a suite can settle into a state where no run ever fires — the seed
|
|
16663
|
-
* deadlock. Appending a few unmeasured-or-aging specs to every selected run
|
|
16664
|
-
* breaks that loop and keeps the whole suite inside the freshness window
|
|
16665
|
-
* without a scheduled full sweep.
|
|
16666
|
-
*/
|
|
16667
|
-
/**
|
|
16668
|
-
* Re-measure once an edge has spent half its lifetime. Half, not "expired":
|
|
16669
|
-
* a spec re-measured only after expiry answers `unknown` for the gap between
|
|
16670
|
-
* expiry and the next run, which is exactly the window this flag exists to
|
|
16671
|
-
* close.
|
|
16672
|
-
*/
|
|
16673
|
-
const REMEASURE_AFTER_MS = EDGE_MAX_AGE_MS / 2;
|
|
16674
|
-
/**
|
|
16675
|
-
* Picks up to `limit` specs from `inventory` worth re-measuring: ones with no
|
|
16676
|
-
* edge at all first (they cost an `unknown` verdict today), then the oldest
|
|
16677
|
-
* measured ones. Specs already selected for this run are never doubled.
|
|
16678
|
-
*/
|
|
16679
|
-
function chooseMeasureBackfill(inventory, selected, edges, limit, now) {
|
|
16680
|
-
const alreadyRunning = new Set(selected.map(specKey));
|
|
16681
|
-
const missing = [];
|
|
16682
|
-
const aging = [];
|
|
16683
|
-
for (const spec of inventory) {
|
|
16684
|
-
if (alreadyRunning.has(specKey(spec))) continue;
|
|
16685
|
-
const edge = edges.get(specKey(spec));
|
|
16686
|
-
if (edge === void 0) missing.push(spec);
|
|
16687
|
-
else if (now - edge.measuredAt > REMEASURE_AFTER_MS) aging.push({
|
|
16688
|
-
spec,
|
|
16689
|
-
measuredAt: edge.measuredAt
|
|
16690
|
-
});
|
|
16691
|
-
}
|
|
16692
|
-
aging.sort((a, b) => a.measuredAt - b.measuredAt);
|
|
16693
|
-
const specs = [...missing, ...aging.map((entry) => entry.spec)].slice(0, limit);
|
|
16694
|
-
const missingTaken = Math.min(missing.length, specs.length);
|
|
16695
|
-
return {
|
|
16696
|
-
specs,
|
|
16697
|
-
missing: missingTaken,
|
|
16698
|
-
aging: specs.length - missingTaken
|
|
16699
|
-
};
|
|
16700
|
-
}
|
|
16701
|
-
//#endregion
|
|
16702
16734
|
//#region src/run/pipeline.ts
|
|
16703
16735
|
async function resolveVitestConfig(cwd) {
|
|
16704
16736
|
const userConfig = resolve(cwd, ".ccqa/vitest.config.ts");
|
|
@@ -16877,10 +16909,6 @@ async function executeRun(targets, opts) {
|
|
|
16877
16909
|
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
|
|
16878
16910
|
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
16879
16911
|
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
|
|
16880
|
-
if ((opts.measureBackfill ?? 0) > 0) {
|
|
16881
|
-
if (opts.coverage !== true) throw new RunUsageError("--measure-backfill does nothing without --coverage — there is no measurement to keep fresh");
|
|
16882
|
-
if (!filtering) throw new RunUsageError("--measure-backfill needs a selection flag (--only-hub-rerun-needed / --only-affected-by); an explicit spec list runs exactly what was asked");
|
|
16883
|
-
}
|
|
16884
16912
|
let coverageInbox;
|
|
16885
16913
|
if (opts.coverageInbox === "hub") {
|
|
16886
16914
|
if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
|
|
@@ -16929,7 +16957,6 @@ async function executeRun(targets, opts) {
|
|
|
16929
16957
|
};
|
|
16930
16958
|
const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
|
|
16931
16959
|
let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
|
|
16932
|
-
const inventory = specs;
|
|
16933
16960
|
if (filtering) {
|
|
16934
16961
|
const before = specs.length;
|
|
16935
16962
|
let inProgress = 0;
|
|
@@ -16960,14 +16987,6 @@ async function executeRun(targets, opts) {
|
|
|
16960
16987
|
throw new RunUsageError("nothing was selected and no spec was cleared to run: exiting non-zero rather than reporting a green run that verified nothing");
|
|
16961
16988
|
}
|
|
16962
16989
|
}
|
|
16963
|
-
if (filtering && (opts.measureBackfill ?? 0) > 0 && hubCtx != null) {
|
|
16964
|
-
const edges = await loadCoverageEdges(hubCtx);
|
|
16965
|
-
const pick = chooseMeasureBackfill(inventory, specs, edges, opts.measureBackfill ?? 0, Date.now());
|
|
16966
|
-
if (pick.specs.length > 0) {
|
|
16967
|
-
specs = [...specs, ...pick.specs];
|
|
16968
|
-
meta("measure-backfill", `${pick.specs.length} spec(s) appended (${pick.missing} unmeasured / ${pick.aging} aging)`);
|
|
16969
|
-
}
|
|
16970
|
-
}
|
|
16971
16990
|
if (specs.length === 0) {
|
|
16972
16991
|
warn("no specs to run");
|
|
16973
16992
|
return {
|
|
@@ -17134,8 +17153,9 @@ async function executeRun(targets, opts) {
|
|
|
17134
17153
|
report: incrementalReport
|
|
17135
17154
|
};
|
|
17136
17155
|
const live = await runLiveSpecs(liveSpecs, liveOpts);
|
|
17156
|
+
let streamedEdges = {};
|
|
17137
17157
|
if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
|
|
17138
|
-
else if (coverage && hubCtx != null) await reportStreamedCoverageHealth(coverage, hubCtx);
|
|
17158
|
+
else if (coverage && hubCtx != null) streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
|
|
17139
17159
|
let overallExitCode = det.exitCode !== 0 ? 1 : 0;
|
|
17140
17160
|
if (live.failedCount > 0) overallExitCode = 1;
|
|
17141
17161
|
if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
|
|
@@ -17179,6 +17199,10 @@ async function executeRun(targets, opts) {
|
|
|
17179
17199
|
coverage
|
|
17180
17200
|
});
|
|
17181
17201
|
completedNormally = true;
|
|
17202
|
+
if (coverage && hubCtx != null) {
|
|
17203
|
+
if (coverage.streamsToHub) await upsertMeasuredEdges(hubCtx, streamedEdges);
|
|
17204
|
+
else if (hubRunId != null && coverage.heardFromApplication()) await upsertMeasuredEdges(hubCtx, Object.fromEntries(report.results.filter((row) => (row.coverage?.files.length ?? 0) > 0).map((row) => [`${row.feature}/${row.spec}`, { files: row.coverage.files }])));
|
|
17205
|
+
}
|
|
17182
17206
|
if (hubRunId) {
|
|
17183
17207
|
const finalStatus = overallExitCode === 0 ? "passed" : "failed";
|
|
17184
17208
|
const reportMeta = buildReportEnvelope({
|
|
@@ -17289,6 +17313,10 @@ function explainMissingCoverage(row) {
|
|
|
17289
17313
|
* Best-effort — the measurement already left as events, so a failed read-out
|
|
17290
17314
|
* loses visibility, never data. Application pushes may still land for
|
|
17291
17315
|
* `GRACE_MS` after the last window closed, so the counts here are a floor.
|
|
17316
|
+
*
|
|
17317
|
+
* Returns the specs the resolve measured, keyed `feature/spec` — the caller
|
|
17318
|
+
* merges them into the ledger alongside the local mode's rows, so the "run
|
|
17319
|
+
* end records measured reach" step lives in one place for both modes.
|
|
17292
17320
|
*/
|
|
17293
17321
|
async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
17294
17322
|
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
@@ -17297,11 +17325,11 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
|
17297
17325
|
resolved = (await hubCtx.hub.getCoverage(hubCtx.project, { runId: coverage.streamRunId })).resolved;
|
|
17298
17326
|
} catch (error) {
|
|
17299
17327
|
warn(`coverage: could not read this run's resolve from the hub (${errMessage(error)})`);
|
|
17300
|
-
return;
|
|
17328
|
+
return {};
|
|
17301
17329
|
}
|
|
17302
17330
|
if (resolved == null) {
|
|
17303
17331
|
warn("coverage: the hub resolved nothing for this run — its events never reached the stream, so every spec's measured reach is absent");
|
|
17304
|
-
return;
|
|
17332
|
+
return {};
|
|
17305
17333
|
}
|
|
17306
17334
|
const measured = resolved.specs.filter((spec) => spec.files.length > 0);
|
|
17307
17335
|
const empty = resolved.specs.filter((spec) => spec.files.length === 0);
|
|
@@ -17320,6 +17348,30 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
|
17320
17348
|
const outside = Object.entries(h.outsideWindowEvents);
|
|
17321
17349
|
if (outside.length > 0) trouble.push(`outside-window=${outside.map(([key, count]) => `${key}:${count}`).join(",")}`);
|
|
17322
17350
|
if (trouble.length > 0) warn(`coverage: stream health flags — ${trouble.join(" ")}`);
|
|
17351
|
+
if (!h.heardFromApplication) return {};
|
|
17352
|
+
return Object.fromEntries(measured.flatMap((spec) => {
|
|
17353
|
+
const key = specKeyFromSpecId(spec.specId, resolved.runId);
|
|
17354
|
+
return key === null ? [] : [[key, {
|
|
17355
|
+
files: spec.files,
|
|
17356
|
+
runId: resolved.runId
|
|
17357
|
+
}]];
|
|
17358
|
+
}));
|
|
17359
|
+
}
|
|
17360
|
+
/**
|
|
17361
|
+
* Merge what this run measured into the hub's coverage-edge ledger
|
|
17362
|
+
* (ADR-0026) — the document selection reads. Best-effort: the measurement
|
|
17363
|
+
* also lives in the stream or the report, so a failed merge loses freshness,
|
|
17364
|
+
* not data. A hub without the endpoint (predating it) warns once and moves on.
|
|
17365
|
+
*/
|
|
17366
|
+
async function upsertMeasuredEdges(hubCtx, specs) {
|
|
17367
|
+
const count = Object.keys(specs).length;
|
|
17368
|
+
if (count === 0) return;
|
|
17369
|
+
try {
|
|
17370
|
+
await hubCtx.hub.putCoverageEdges(hubCtx.project, { specs });
|
|
17371
|
+
meta("coverage", `${count} spec edge(s) recorded on the hub's ledger`);
|
|
17372
|
+
} catch (error) {
|
|
17373
|
+
warn(`coverage: could not record measured edges on the hub (${errMessage(error)})`);
|
|
17374
|
+
}
|
|
17323
17375
|
}
|
|
17324
17376
|
/** Everything the measurement could not place; silence here reads as "never reached". */
|
|
17325
17377
|
function reportCoverageHealth(coverage, rows) {
|
|
@@ -17940,11 +17992,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
|
|
|
17940
17992
|
}, "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.").option("--coverage-inbox <where>", "With --coverage: where the measurement's two sides meet. 'local' (default) binds a loopback inbox on this machine for the run's duration; 'hub' appends every event to the hub's durable coverage inbox instead — nothing listens on the runner, report.json carries no coverage, and the hub resolves per-spec results on read (requires a hub connection).", (raw) => {
|
|
17941
17993
|
if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
|
|
17942
17994
|
throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
|
|
17943
|
-
}, "local").option("--
|
|
17944
|
-
const n = Number(raw);
|
|
17945
|
-
if (!Number.isInteger(n) || n < 0) throw new Error("--measure-backfill must be a non-negative integer");
|
|
17946
|
-
return n;
|
|
17947
|
-
}).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) => {
|
|
17995
|
+
}, "local").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) => {
|
|
17948
17996
|
await runCliAction(targets, opts);
|
|
17949
17997
|
});
|
|
17950
17998
|
/** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
|
|
@@ -21503,7 +21551,7 @@ async function runSelectSpecs(opts) {
|
|
|
21503
21551
|
project: opts.project,
|
|
21504
21552
|
cwd: opts.cwd
|
|
21505
21553
|
});
|
|
21506
|
-
const [specsResult, changedResult,
|
|
21554
|
+
const [specsResult, changedResult, edgesReadout] = await Promise.all([
|
|
21507
21555
|
loadSpecInventory(cwd).then((specs) => ({
|
|
21508
21556
|
ok: true,
|
|
21509
21557
|
specs
|
|
@@ -21543,7 +21591,7 @@ async function runSelectSpecs(opts) {
|
|
|
21543
21591
|
meta("project", project);
|
|
21544
21592
|
meta("changed-files", changed.length);
|
|
21545
21593
|
meta("specs", specs.length);
|
|
21546
|
-
meta("measured-specs", edges.size);
|
|
21594
|
+
meta("measured-specs", edgesReadout.edges.size);
|
|
21547
21595
|
}
|
|
21548
21596
|
const report = await selectSpecs({
|
|
21549
21597
|
changed,
|
|
@@ -21551,7 +21599,7 @@ async function runSelectSpecs(opts) {
|
|
|
21551
21599
|
cwd,
|
|
21552
21600
|
base: opts.base,
|
|
21553
21601
|
head,
|
|
21554
|
-
edges
|
|
21602
|
+
edges: edgesReadout
|
|
21555
21603
|
});
|
|
21556
21604
|
process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
|
|
21557
21605
|
process.exit(0);
|
|
@@ -23471,6 +23519,32 @@ function requireSinceSeqParam(url) {
|
|
|
23471
23519
|
if (!Number.isInteger(value) || value < 0) throw new HttpError(400, "invalid_param", "invalid sinceSeq: must be a non-negative integer");
|
|
23472
23520
|
return value;
|
|
23473
23521
|
}
|
|
23522
|
+
const MAX_EDGES_BODY_BYTES = 8 * 1024 * 1024;
|
|
23523
|
+
/**
|
|
23524
|
+
* PUT /api/v1/projects/:project/coverage-edges — the specs one run measured,
|
|
23525
|
+
* merged into the ledger newest-wins. `measuredAt` is stamped here with the
|
|
23526
|
+
* hub's clock, so entries written by different runners stay comparable.
|
|
23527
|
+
* Bearer-authenticated like every project route; the append-only coverage
|
|
23528
|
+
* token cannot write here.
|
|
23529
|
+
*/
|
|
23530
|
+
function createPutCoverageEdgesHandler(config) {
|
|
23531
|
+
return async (ctx) => {
|
|
23532
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
23533
|
+
const body = await readJsonBody(ctx.req, MAX_EDGES_BODY_BYTES, CoverageEdgesUpsertSchema, "coverage-edges body");
|
|
23534
|
+
await config.store.merge(project, body.specs, Date.now());
|
|
23535
|
+
ctx.res.statusCode = 204;
|
|
23536
|
+
ctx.res.end();
|
|
23537
|
+
};
|
|
23538
|
+
}
|
|
23539
|
+
/** GET /api/v1/projects/:project/coverage-edges — the ledger, or 404. */
|
|
23540
|
+
function createGetCoverageEdgesHandler(config) {
|
|
23541
|
+
return async (ctx) => {
|
|
23542
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
23543
|
+
const doc = await config.store.get(project);
|
|
23544
|
+
if (doc === null) throw new HttpError(404, "not_found", `no coverage edges stored for project "${project}"`);
|
|
23545
|
+
sendJson(ctx.res, 200, doc);
|
|
23546
|
+
};
|
|
23547
|
+
}
|
|
23474
23548
|
//#endregion
|
|
23475
23549
|
//#region src/hub/core/rerun.ts
|
|
23476
23550
|
/**
|
|
@@ -30781,6 +30855,9 @@ function registerRoutes(router, config, queue) {
|
|
|
30781
30855
|
router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
|
|
30782
30856
|
router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
|
|
30783
30857
|
router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
|
|
30858
|
+
const coverageEdgesConfig = { store: storage.coverageEdges };
|
|
30859
|
+
router.put("/api/v1/projects/:project/coverage-edges", createPutCoverageEdgesHandler(coverageEdgesConfig));
|
|
30860
|
+
router.get("/api/v1/projects/:project/coverage-edges", createGetCoverageEdgesHandler(coverageEdgesConfig));
|
|
30784
30861
|
const coverageConfig = {
|
|
30785
30862
|
store: storage.coverageEvents,
|
|
30786
30863
|
encryptionKey: config.encryptionKey,
|
|
@@ -30886,6 +30963,9 @@ function perspectivesKindDir(root) {
|
|
|
30886
30963
|
function perspectivesPath(root, project) {
|
|
30887
30964
|
return join(perspectivesKindDir(root), `${project}.json`);
|
|
30888
30965
|
}
|
|
30966
|
+
function coverageEdgesPath(root, project) {
|
|
30967
|
+
return join(root, "coverage-edges", `${project}.json`);
|
|
30968
|
+
}
|
|
30889
30969
|
function ledgerProfileDir(root, project, profile) {
|
|
30890
30970
|
return join(root, "last-green", project, profile);
|
|
30891
30971
|
}
|
|
@@ -31387,6 +31467,47 @@ function createFileSpecLedgerStore(root) {
|
|
|
31387
31467
|
};
|
|
31388
31468
|
}
|
|
31389
31469
|
//#endregion
|
|
31470
|
+
//#region src/hub/core/storage/file/coverage-edge-store.ts
|
|
31471
|
+
/**
|
|
31472
|
+
* Coverage-edge ledger (ADR-0026): one JSON document per project. `merge`
|
|
31473
|
+
* goes through `updateJson`, so two runs finishing at once queue their
|
|
31474
|
+
* read-modify-writes instead of clobbering each other, and each entry only
|
|
31475
|
+
* ever moves forward — a run's measurement replaces a spec's entry, never
|
|
31476
|
+
* deletes another spec's.
|
|
31477
|
+
*/
|
|
31478
|
+
function createFileCoverageEdgeStore(root) {
|
|
31479
|
+
return {
|
|
31480
|
+
async get(project) {
|
|
31481
|
+
assertSafeName(project, "project");
|
|
31482
|
+
const raw = await readJson(coverageEdgesPath(root, project));
|
|
31483
|
+
return raw === null ? null : parseDoc(raw, project);
|
|
31484
|
+
},
|
|
31485
|
+
async merge(project, specs, measuredAt) {
|
|
31486
|
+
assertSafeName(project, "project");
|
|
31487
|
+
await updateJson(coverageEdgesPath(root, project), (current) => {
|
|
31488
|
+
const doc = current === null ? { specs: {} } : parseDoc(current, project);
|
|
31489
|
+
for (const [key, entry] of Object.entries(specs)) doc.specs[key] = {
|
|
31490
|
+
files: [...entry.files].sort(),
|
|
31491
|
+
measuredAt,
|
|
31492
|
+
...entry.runId === void 0 ? {} : { runId: entry.runId }
|
|
31493
|
+
};
|
|
31494
|
+
return doc;
|
|
31495
|
+
});
|
|
31496
|
+
}
|
|
31497
|
+
};
|
|
31498
|
+
}
|
|
31499
|
+
/**
|
|
31500
|
+
* A present document that does not parse is an error, never an empty ledger:
|
|
31501
|
+
* treating it as empty would let the next merge silently discard every other
|
|
31502
|
+
* spec's edge, and the data is regenerated only by running every spec
|
|
31503
|
+
* measured again.
|
|
31504
|
+
*/
|
|
31505
|
+
function parseDoc(raw, project) {
|
|
31506
|
+
const parsed = CoverageEdgesDocSchema.safeParse(raw);
|
|
31507
|
+
if (!parsed.success) throw new Error(`coverage-edges document for project "${project}" does not match the schema`);
|
|
31508
|
+
return parsed.data;
|
|
31509
|
+
}
|
|
31510
|
+
//#endregion
|
|
31390
31511
|
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
31391
31512
|
/**
|
|
31392
31513
|
* Perspectives storage: one JSON document per project, plain UTF-8 with no
|
|
@@ -31693,6 +31814,7 @@ function createFileHubStorage(dataDir) {
|
|
|
31693
31814
|
triage: createFileTriageStore(dataDir),
|
|
31694
31815
|
prompts: createFilePromptStore(dataDir),
|
|
31695
31816
|
perspectives: createFilePerspectivesStore(dataDir),
|
|
31817
|
+
coverageEdges: createFileCoverageEdgeStore(dataDir),
|
|
31696
31818
|
jobs: createFileJobStore(dataDir),
|
|
31697
31819
|
ledger: createFileSpecLedgerStore(dataDir),
|
|
31698
31820
|
driftLedger: createFileDriftLedgerStore(dataDir),
|
|
@@ -547,6 +547,26 @@ declare const SpendLogResponseSchema: z.ZodObject<{
|
|
|
547
547
|
}, z.core.$strip>>;
|
|
548
548
|
}, z.core.$strip>;
|
|
549
549
|
type SpendLogResponse = z.infer<typeof SpendLogResponseSchema>;
|
|
550
|
+
declare const CoverageEdgesDocSchema: z.ZodObject<{
|
|
551
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
552
|
+
files: z.ZodArray<z.ZodString>;
|
|
553
|
+
measuredAt: z.ZodNumber;
|
|
554
|
+
runId: z.ZodOptional<z.ZodString>;
|
|
555
|
+
}, z.core.$strip>>;
|
|
556
|
+
}, z.core.$strip>;
|
|
557
|
+
type CoverageEdgesDoc = z.infer<typeof CoverageEdgesDocSchema>;
|
|
558
|
+
/**
|
|
559
|
+
* Body of `PUT /projects/:project/coverage-edges` — the specs one run
|
|
560
|
+
* measured. Merged into the stored document, never replacing other specs'
|
|
561
|
+
* entries; an empty file set is not a measurement and is rejected per entry.
|
|
562
|
+
*/
|
|
563
|
+
declare const CoverageEdgesUpsertSchema: z.ZodObject<{
|
|
564
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
565
|
+
files: z.ZodArray<z.ZodString>;
|
|
566
|
+
runId: z.ZodOptional<z.ZodString>;
|
|
567
|
+
}, z.core.$strip>>;
|
|
568
|
+
}, z.core.$strip>;
|
|
569
|
+
type CoverageEdgesUpsert = z.infer<typeof CoverageEdgesUpsertSchema>;
|
|
550
570
|
//#endregion
|
|
551
571
|
//#region src/coverage/resolve-stream.d.ts
|
|
552
572
|
/**
|
|
@@ -1159,6 +1179,13 @@ interface HubClient {
|
|
|
1159
1179
|
getCoverage(project: string, q?: {
|
|
1160
1180
|
runId?: string;
|
|
1161
1181
|
}): Promise<HubCoverageAnswer>;
|
|
1182
|
+
/**
|
|
1183
|
+
* Merge one run's measured reach into the project's coverage-edge ledger
|
|
1184
|
+
* (ADR-0026). The hub stamps `measuredAt` with its own clock.
|
|
1185
|
+
*/
|
|
1186
|
+
putCoverageEdges(project: string, upsert: CoverageEdgesUpsert): Promise<void>;
|
|
1187
|
+
/** The coverage-edge ledger, or `null` when nothing is stored (or the hub predates it). */
|
|
1188
|
+
getCoverageEdges(project: string): Promise<CoverageEdgesDoc | null>;
|
|
1162
1189
|
/**
|
|
1163
1190
|
* Source maps for what a commit deployed, addressed by the asset path the
|
|
1164
1191
|
* browser requests. Coverage falls back to these when the build keeps its
|
|
@@ -153,6 +153,21 @@ function createHubClient(opts) {
|
|
|
153
153
|
runId: q.runId
|
|
154
154
|
})}`);
|
|
155
155
|
},
|
|
156
|
+
putCoverageEdges(project, upsert) {
|
|
157
|
+
return request(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`, {
|
|
158
|
+
method: "PUT",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify(upsert)
|
|
161
|
+
}).then(() => void 0);
|
|
162
|
+
},
|
|
163
|
+
async getCoverageEdges(project) {
|
|
164
|
+
try {
|
|
165
|
+
return await json(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof HubApiError && err.status === 404) return null;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
156
171
|
putSourceMap(project, commit, assetPath, map) {
|
|
157
172
|
return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
|
|
158
173
|
method: "PUT",
|
package/dist/package.json
CHANGED