ccqa 1.40.8 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/ccqa.mjs +467 -173
- package/dist/hub-client/index.d.mts +10 -0
- package/dist/hub-client/index.mjs +33 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -6422,6 +6422,7 @@ var FrontendResolution = class {
|
|
|
6422
6422
|
coverageDir;
|
|
6423
6423
|
roots;
|
|
6424
6424
|
fetchText;
|
|
6425
|
+
fetchStoredMap;
|
|
6425
6426
|
warn;
|
|
6426
6427
|
files = /* @__PURE__ */ new Set();
|
|
6427
6428
|
/**
|
|
@@ -6448,6 +6449,7 @@ var FrontendResolution = class {
|
|
|
6448
6449
|
this.coverageDir = opts.coverageDir;
|
|
6449
6450
|
this.roots = opts.roots;
|
|
6450
6451
|
this.fetchText = opts.fetchText;
|
|
6452
|
+
this.fetchStoredMap = opts.fetchStoredMap;
|
|
6451
6453
|
this.warn = opts.warn;
|
|
6452
6454
|
}
|
|
6453
6455
|
async absorb(script) {
|
|
@@ -6538,24 +6540,46 @@ var FrontendResolution = class {
|
|
|
6538
6540
|
async fetchSourceMap(script) {
|
|
6539
6541
|
const source = await script.source();
|
|
6540
6542
|
if (source === void 0) return void 0;
|
|
6543
|
+
for (const load of this.sourceMapLoaders(script, source)) {
|
|
6544
|
+
const json = await load();
|
|
6545
|
+
if (json === void 0) continue;
|
|
6546
|
+
const map = parseSourceMap(json);
|
|
6547
|
+
if (map !== void 0) return prepareSourceMap(map, source);
|
|
6548
|
+
}
|
|
6549
|
+
}
|
|
6550
|
+
/**
|
|
6551
|
+
* Ways to get a map for `script`, most authoritative first and none of them
|
|
6552
|
+
* taken until the one before it fails. A map the script points at describes
|
|
6553
|
+
* the code that ran, so the stored copy is only asked for when that is
|
|
6554
|
+
* absent or turns out not to be a map — a catch-all route answers a missing
|
|
6555
|
+
* `.map` with an HTML page, which is a body but not a map. Reading it
|
|
6556
|
+
* eagerly would put a request on the wire for every script of every spec,
|
|
6557
|
+
* nearly all of them misses on a deployment that pushes nothing.
|
|
6558
|
+
*/
|
|
6559
|
+
sourceMapLoaders(script, source) {
|
|
6560
|
+
const loaders = [];
|
|
6541
6561
|
const reference = readSourceMappingUrl(source);
|
|
6542
|
-
if (reference
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
target
|
|
6548
|
-
} catch {
|
|
6549
|
-
return;
|
|
6562
|
+
if (reference !== void 0) {
|
|
6563
|
+
const inline = decodeInlineSourceMap(reference);
|
|
6564
|
+
if (inline !== void 0) loaders.push(() => Promise.resolve(inline));
|
|
6565
|
+
else {
|
|
6566
|
+
const target = absoluteOrUndefined(reference, script.url);
|
|
6567
|
+
if (target !== void 0) loaders.push(() => this.fetchText(target));
|
|
6550
6568
|
}
|
|
6551
|
-
json = await this.fetchText(target);
|
|
6552
|
-
if (json === void 0) return void 0;
|
|
6553
6569
|
}
|
|
6554
|
-
const
|
|
6555
|
-
if (
|
|
6556
|
-
return
|
|
6570
|
+
const stored = this.fetchStoredMap;
|
|
6571
|
+
if (stored !== void 0) loaders.push(() => stored(script.url));
|
|
6572
|
+
return loaders;
|
|
6557
6573
|
}
|
|
6558
6574
|
};
|
|
6575
|
+
/** `reference` resolved against the script's URL, or undefined when it is not a URL. */
|
|
6576
|
+
function absoluteOrUndefined(reference, scriptUrl) {
|
|
6577
|
+
try {
|
|
6578
|
+
return new URL(reference, scriptUrl).toString();
|
|
6579
|
+
} catch {
|
|
6580
|
+
return;
|
|
6581
|
+
}
|
|
6582
|
+
}
|
|
6559
6583
|
function message$2(error) {
|
|
6560
6584
|
return error instanceof Error ? error.message : String(error);
|
|
6561
6585
|
}
|
|
@@ -7149,6 +7173,7 @@ var Engine = class {
|
|
|
7149
7173
|
coverageDir: opts.coverageDir,
|
|
7150
7174
|
roots: opts.roots,
|
|
7151
7175
|
fetchText: (url) => this.fetchThroughBrowser(url),
|
|
7176
|
+
fetchStoredMap: (scriptUrl) => this.storedMapFor(scriptUrl),
|
|
7152
7177
|
warn: opts.warn
|
|
7153
7178
|
});
|
|
7154
7179
|
}
|
|
@@ -7414,6 +7439,23 @@ var Engine = class {
|
|
|
7414
7439
|
return;
|
|
7415
7440
|
}
|
|
7416
7441
|
}
|
|
7442
|
+
/**
|
|
7443
|
+
* The map a deploy stored for `scriptUrl`, if this run knows where to ask.
|
|
7444
|
+
* Addressed by the path under the asset origin rather than the URL, so the
|
|
7445
|
+
* push and the read agree without either knowing the other's host.
|
|
7446
|
+
*/
|
|
7447
|
+
async storedMapFor(scriptUrl) {
|
|
7448
|
+
const stored = this.opts.fetchStoredSourceMap;
|
|
7449
|
+
if (stored === void 0) return void 0;
|
|
7450
|
+
const assetPath = assetPathOf(scriptUrl, [...this.opts.origins, ...this.opts.assetOrigins ?? []]);
|
|
7451
|
+
if (assetPath === void 0) return void 0;
|
|
7452
|
+
try {
|
|
7453
|
+
return await stored(`${assetPath}.map`);
|
|
7454
|
+
} catch (err) {
|
|
7455
|
+
this.opts.warn(`could not read the stored source map for ${assetPath}: ${message(err)}`);
|
|
7456
|
+
return;
|
|
7457
|
+
}
|
|
7458
|
+
}
|
|
7417
7459
|
async readStream(handle, sessionId) {
|
|
7418
7460
|
const parts = [];
|
|
7419
7461
|
for (;;) {
|
|
@@ -7428,6 +7470,25 @@ var Engine = class {
|
|
|
7428
7470
|
function message(error) {
|
|
7429
7471
|
return error instanceof Error ? error.message : String(error);
|
|
7430
7472
|
}
|
|
7473
|
+
/**
|
|
7474
|
+
* The path a stored map is filed under: the URL with its origin removed, and
|
|
7475
|
+
* with any deploy-scoped prefix (`/assets/<sha>/`) left in place — that prefix
|
|
7476
|
+
* is part of what the browser asked for, so the push has to have used it too.
|
|
7477
|
+
* A URL from an origin the run never declared is not ours to look up.
|
|
7478
|
+
*/
|
|
7479
|
+
function assetPathOf(url, origins) {
|
|
7480
|
+
let parsed;
|
|
7481
|
+
try {
|
|
7482
|
+
parsed = new URL(url);
|
|
7483
|
+
} catch {
|
|
7484
|
+
return;
|
|
7485
|
+
}
|
|
7486
|
+
const path = `${parsed.origin}${parsed.pathname}`;
|
|
7487
|
+
for (const origin of origins) {
|
|
7488
|
+
const base = origin.replace(/\/+$/, "");
|
|
7489
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length + 1);
|
|
7490
|
+
}
|
|
7491
|
+
}
|
|
7431
7492
|
//#endregion
|
|
7432
7493
|
//#region src/coverage/universe.ts
|
|
7433
7494
|
/**
|
|
@@ -7503,8 +7564,20 @@ async function walk(abs, rel, out, warn) {
|
|
|
7503
7564
|
const SETTLE_POLL_MS = 250;
|
|
7504
7565
|
const SETTLE_QUIET_POLLS = 10;
|
|
7505
7566
|
const SETTLE_CAP_MS = 1e4;
|
|
7567
|
+
/**
|
|
7568
|
+
* Origins with their `${VAR}`s resolved, refusing anything that is not an
|
|
7569
|
+
* absolute http(s) URL. An unset variable resolves to the empty string, which
|
|
7570
|
+
* would otherwise match nothing and take the feature down without a word.
|
|
7571
|
+
*/
|
|
7572
|
+
function resolveAbsoluteOrigins(origins, label) {
|
|
7573
|
+
const resolved = origins.map((origin) => resolveEnvRefs(origin));
|
|
7574
|
+
const unresolved = resolved.filter((origin) => !/^https?:\/\//i.test(origin));
|
|
7575
|
+
if (unresolved.length > 0) throw new Error(`${label} must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
|
|
7576
|
+
return resolved;
|
|
7577
|
+
}
|
|
7506
7578
|
var CoverageSession = class CoverageSession {
|
|
7507
7579
|
existing = /* @__PURE__ */ new Map();
|
|
7580
|
+
fetchStoredSourceMap;
|
|
7508
7581
|
/**
|
|
7509
7582
|
* Undefined in hub mode: nothing binds on the runner, and every read-side
|
|
7510
7583
|
* answer here stays empty — interpretation lives in the hub's resolve
|
|
@@ -7525,13 +7598,15 @@ var CoverageSession = class CoverageSession {
|
|
|
7525
7598
|
cwd;
|
|
7526
7599
|
actors;
|
|
7527
7600
|
origins;
|
|
7601
|
+
/** Where this project's assets come from — the cookie never goes here. */
|
|
7602
|
+
assetOrigins;
|
|
7528
7603
|
/**
|
|
7529
7604
|
* The denominator, enumerated once at start, or undefined when
|
|
7530
7605
|
* `coverage.include` is unset — and always in hub mode, where it travels as
|
|
7531
7606
|
* a `universe` event instead of riding the report envelope.
|
|
7532
7607
|
*/
|
|
7533
7608
|
universe;
|
|
7534
|
-
constructor(sink, inbox, runId, root, cwd, actors, origins, universe) {
|
|
7609
|
+
constructor(sink, inbox, runId, root, cwd, actors, origins, assetOrigins, universe, fetchStoredSourceMap) {
|
|
7535
7610
|
this.sink = sink;
|
|
7536
7611
|
this.inbox = inbox;
|
|
7537
7612
|
this.runId = runId;
|
|
@@ -7539,12 +7614,12 @@ var CoverageSession = class CoverageSession {
|
|
|
7539
7614
|
this.cwd = cwd;
|
|
7540
7615
|
this.actors = actors;
|
|
7541
7616
|
this.origins = origins;
|
|
7617
|
+
this.assetOrigins = assetOrigins;
|
|
7542
7618
|
this.universe = universe;
|
|
7619
|
+
this.fetchStoredSourceMap = fetchStoredSourceMap;
|
|
7543
7620
|
}
|
|
7544
7621
|
static async start(options) {
|
|
7545
|
-
const origins = options.config.instrumentedOrigins.
|
|
7546
|
-
const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
|
|
7547
|
-
if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
|
|
7622
|
+
const origins = resolveAbsoluteOrigins(options.config.instrumentedOrigins, "coverage.instrumentedOrigins");
|
|
7548
7623
|
const actors = options.actors ?? NO_ACTORS;
|
|
7549
7624
|
let sink;
|
|
7550
7625
|
if (options.inbox === void 0) {
|
|
@@ -7560,7 +7635,7 @@ var CoverageSession = class CoverageSession {
|
|
|
7560
7635
|
include: [...universe.include],
|
|
7561
7636
|
files: [...universe.files]
|
|
7562
7637
|
});
|
|
7563
|
-
return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, options.inbox === void 0 ? universe : void 0);
|
|
7638
|
+
return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, resolveAbsoluteOrigins(options.config.assetOrigins ?? [], "coverage.assetOrigins"), options.inbox === void 0 ? universe : void 0, options.fetchStoredSourceMap);
|
|
7564
7639
|
}
|
|
7565
7640
|
/**
|
|
7566
7641
|
* Ties the stream's run id to the hub's run record. The session starts
|
|
@@ -7635,12 +7710,14 @@ var CoverageSession = class CoverageSession {
|
|
|
7635
7710
|
cdpUrl,
|
|
7636
7711
|
specId: specIdFor(this.runId, ref),
|
|
7637
7712
|
origins: this.origins,
|
|
7713
|
+
assetOrigins: this.assetOrigins,
|
|
7638
7714
|
coverageDir,
|
|
7639
7715
|
roots: {
|
|
7640
7716
|
base: this.cwd,
|
|
7641
7717
|
root: this.root
|
|
7642
7718
|
},
|
|
7643
|
-
warn: (text) => warn(`coverage: ${text}`)
|
|
7719
|
+
warn: (text) => warn(`coverage: ${text}`),
|
|
7720
|
+
fetchStoredSourceMap: this.fetchStoredSourceMap
|
|
7644
7721
|
});
|
|
7645
7722
|
}
|
|
7646
7723
|
/**
|
|
@@ -10843,6 +10920,177 @@ function readOctal(buf, offset, fieldLen) {
|
|
|
10843
10920
|
return str === "" ? 0 : parseInt(str, 8);
|
|
10844
10921
|
}
|
|
10845
10922
|
//#endregion
|
|
10923
|
+
//#region src/hub/core/storage/file/fs-helpers.ts
|
|
10924
|
+
/**
|
|
10925
|
+
* Defense-in-depth for a name this layer joins into a file path. The API's
|
|
10926
|
+
* `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
|
|
10927
|
+
* charset and a length — and stays the rule for what a client may name; this
|
|
10928
|
+
* only refuses traversal, so it also covers callers that never came through
|
|
10929
|
+
* HTTP (tests, a library embedding the hub).
|
|
10930
|
+
*/
|
|
10931
|
+
function assertSafeName(value, label) {
|
|
10932
|
+
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
10933
|
+
}
|
|
10934
|
+
/** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
|
|
10935
|
+
async function readJson(path) {
|
|
10936
|
+
let raw;
|
|
10937
|
+
try {
|
|
10938
|
+
raw = await readFile(path, "utf8");
|
|
10939
|
+
} catch (err) {
|
|
10940
|
+
if (isNotFound(err)) return null;
|
|
10941
|
+
throw err;
|
|
10942
|
+
}
|
|
10943
|
+
try {
|
|
10944
|
+
return JSON.parse(raw);
|
|
10945
|
+
} catch (err) {
|
|
10946
|
+
throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
10947
|
+
}
|
|
10948
|
+
}
|
|
10949
|
+
/**
|
|
10950
|
+
* Write `data` to a temp file in the same directory, then atomically rename it
|
|
10951
|
+
* into place, so a concurrent reader only ever observes the old file or the
|
|
10952
|
+
* fully-written new one — never the empty/partial window a plain
|
|
10953
|
+
* truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
|
|
10954
|
+
* triage file while an API request reads it). The temp file lives in the same
|
|
10955
|
+
* directory as its target to keep the rename on one filesystem, where it is
|
|
10956
|
+
* atomic.
|
|
10957
|
+
*/
|
|
10958
|
+
async function atomicWrite(path, data) {
|
|
10959
|
+
await mkdir(dirname(path), { recursive: true });
|
|
10960
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
10961
|
+
await writeFile(tmp, data);
|
|
10962
|
+
await rename(tmp, path);
|
|
10963
|
+
}
|
|
10964
|
+
/** Write `value` as pretty JSON, creating parent directories as needed. */
|
|
10965
|
+
async function writeJson(path, value) {
|
|
10966
|
+
await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
|
|
10967
|
+
}
|
|
10968
|
+
/**
|
|
10969
|
+
* Per-path serialization for read-modify-write JSON updates. Two concurrent
|
|
10970
|
+
* `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
|
|
10971
|
+
* on the same run's triage file) would otherwise both read the same starting
|
|
10972
|
+
* state and the second writer's change would silently clobber the first's —
|
|
10973
|
+
* this queues the second call's read until the first's write has landed, so
|
|
10974
|
+
* updates apply in the order they were issued rather than racing. Scoped by
|
|
10975
|
+
* path (a `Map` of chained promises), not globally, so unrelated records still
|
|
10976
|
+
* update concurrently.
|
|
10977
|
+
*/
|
|
10978
|
+
const updateChains = /* @__PURE__ */ new Map();
|
|
10979
|
+
/**
|
|
10980
|
+
* Queue `work` behind whatever is already in flight for `path`. A delete takes
|
|
10981
|
+
* the same chain as the updates it removes: `writeJson` recreates the parent
|
|
10982
|
+
* directory, so an unordered delete would be silently undone by an update that
|
|
10983
|
+
* was already queued.
|
|
10984
|
+
*/
|
|
10985
|
+
async function serialize(path, work) {
|
|
10986
|
+
const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
|
|
10987
|
+
updateChains.set(path, next);
|
|
10988
|
+
try {
|
|
10989
|
+
return await next;
|
|
10990
|
+
} finally {
|
|
10991
|
+
if (updateChains.get(path) === next) updateChains.delete(path);
|
|
10992
|
+
}
|
|
10993
|
+
}
|
|
10994
|
+
async function updateJson(path, mutate) {
|
|
10995
|
+
return await serialize(path, async () => {
|
|
10996
|
+
const updated = mutate(await readJson(path));
|
|
10997
|
+
await writeJson(path, updated);
|
|
10998
|
+
return updated;
|
|
10999
|
+
});
|
|
11000
|
+
}
|
|
11001
|
+
/** Read a raw file, returning `null` when it doesn't exist. */
|
|
11002
|
+
async function readBytesOrNull(path) {
|
|
11003
|
+
try {
|
|
11004
|
+
const buf = await readFile(path);
|
|
11005
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
11006
|
+
} catch (err) {
|
|
11007
|
+
if (isNotFound(err)) return null;
|
|
11008
|
+
throw err;
|
|
11009
|
+
}
|
|
11010
|
+
}
|
|
11011
|
+
async function writeBytes(path, bytes) {
|
|
11012
|
+
await atomicWrite(path, bytes);
|
|
11013
|
+
}
|
|
11014
|
+
/** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
|
|
11015
|
+
async function listDirOrEmpty(dir) {
|
|
11016
|
+
try {
|
|
11017
|
+
return await readdir(dir);
|
|
11018
|
+
} catch (err) {
|
|
11019
|
+
if (isNotFound(err)) return [];
|
|
11020
|
+
throw err;
|
|
11021
|
+
}
|
|
11022
|
+
}
|
|
11023
|
+
/**
|
|
11024
|
+
* Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
|
|
11025
|
+
* Skips files and dot-entries so stray filesystem litter (e.g. a Finder
|
|
11026
|
+
* `.DS_Store`) never surfaces as a record id or project name.
|
|
11027
|
+
*/
|
|
11028
|
+
async function listSubdirsOrEmpty(dir) {
|
|
11029
|
+
try {
|
|
11030
|
+
return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
11031
|
+
} catch (err) {
|
|
11032
|
+
if (isNotFound(err)) return [];
|
|
11033
|
+
throw err;
|
|
11034
|
+
}
|
|
11035
|
+
}
|
|
11036
|
+
/** Every file path under `dir`, relative to `dir`, posix-separated. */
|
|
11037
|
+
async function listFilesRecursive(dir) {
|
|
11038
|
+
const out = [];
|
|
11039
|
+
async function walk(current) {
|
|
11040
|
+
let entries;
|
|
11041
|
+
try {
|
|
11042
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
11043
|
+
} catch (err) {
|
|
11044
|
+
if (isNotFound(err)) return;
|
|
11045
|
+
throw err;
|
|
11046
|
+
}
|
|
11047
|
+
for (const entry of entries) {
|
|
11048
|
+
const abs = join(current, entry.name);
|
|
11049
|
+
if (entry.isDirectory()) await walk(abs);
|
|
11050
|
+
else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
|
|
11051
|
+
}
|
|
11052
|
+
}
|
|
11053
|
+
await walk(dir);
|
|
11054
|
+
return out;
|
|
11055
|
+
}
|
|
11056
|
+
async function removePath(path) {
|
|
11057
|
+
await rm(path, {
|
|
11058
|
+
recursive: true,
|
|
11059
|
+
force: true
|
|
11060
|
+
});
|
|
11061
|
+
}
|
|
11062
|
+
function isNotFound(err) {
|
|
11063
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
11064
|
+
}
|
|
11065
|
+
//#endregion
|
|
11066
|
+
//#region src/coverage/frontend/reduce-map.ts
|
|
11067
|
+
/**
|
|
11068
|
+
* `json` reduced to the readable fields, or undefined when it is not a source
|
|
11069
|
+
* map this side can use — the same refusals `parseSourceMap` makes, applied at
|
|
11070
|
+
* push time so an unusable map is never stored.
|
|
11071
|
+
*/
|
|
11072
|
+
function reduceSourceMap(json) {
|
|
11073
|
+
let parsed;
|
|
11074
|
+
try {
|
|
11075
|
+
parsed = JSON.parse(json);
|
|
11076
|
+
} catch {
|
|
11077
|
+
return;
|
|
11078
|
+
}
|
|
11079
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
11080
|
+
const map = parsed;
|
|
11081
|
+
if (map.sections !== void 0) return void 0;
|
|
11082
|
+
if (map.version !== 3) return void 0;
|
|
11083
|
+
if (typeof map.mappings !== "string") return void 0;
|
|
11084
|
+
if (!Array.isArray(map.sources)) return void 0;
|
|
11085
|
+
return {
|
|
11086
|
+
version: 3,
|
|
11087
|
+
sources: map.sources,
|
|
11088
|
+
mappings: map.mappings,
|
|
11089
|
+
...typeof map.sourceRoot === "string" ? { sourceRoot: map.sourceRoot } : {},
|
|
11090
|
+
...typeof map.file === "string" ? { file: map.file } : {}
|
|
11091
|
+
};
|
|
11092
|
+
}
|
|
11093
|
+
//#endregion
|
|
10846
11094
|
//#region src/runtime/session-state.ts
|
|
10847
11095
|
/** Default per-profile sessions root, relative to the project (`--cwd`). */
|
|
10848
11096
|
const SESSIONS_SUBDIR = ".ccqa/sessions";
|
|
@@ -11401,6 +11649,7 @@ const CoverageActorsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i
|
|
|
11401
11649
|
*/
|
|
11402
11650
|
const CoverageConfigSchema = z.object({
|
|
11403
11651
|
instrumentedOrigins: z.array(z.string().min(1)).min(1),
|
|
11652
|
+
assetOrigins: z.array(z.string().min(1)).optional(),
|
|
11404
11653
|
sink: z.string().min(1).default("http://127.0.0.1:4757"),
|
|
11405
11654
|
projectRoot: z.string().min(1).optional(),
|
|
11406
11655
|
include: z.array(z.string().min(1)).optional(),
|
|
@@ -12307,6 +12556,49 @@ const varRm = new Command("rm").description("Delete a variable from the hub.").a
|
|
|
12307
12556
|
info(`deleted variable "${name}" from the hub`);
|
|
12308
12557
|
}));
|
|
12309
12558
|
const varCommand = new Command("var").description("Manage environment variables stored on the hub (fetched at run time by `ccqa run` / `ccqa record`).").addCommand(varSet).addCommand(varLs).addCommand(varRm);
|
|
12559
|
+
const sourcemapPush = new Command("push").description("Upload the source maps a build produced, so `ccqa run --coverage` can name frontend files when the build keeps its maps off the CDN. Only the fields coverage reads are sent — the original source each map carries is dropped rather than stored.").argument("<dir>", "Directory holding the build output (e.g. `.next/static`).").requiredOption("--sha <sha>", "Commit these assets were built from — the same one the deploy is recorded under.").option("--asset-prefix <path>", "Path the files are served under, prepended to each stored path (e.g. `_next/static`). Match what the browser requests, minus the origin.", "").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (dirArg, opts) => {
|
|
12560
|
+
const project = resolveProject(opts);
|
|
12561
|
+
const hub = connect$2(opts);
|
|
12562
|
+
const dir = resolve(resolveCwd(opts.cwd), dirArg);
|
|
12563
|
+
if (!existsSync(dir)) throw new Error(`no such directory: ${dir}`);
|
|
12564
|
+
const maps = (await listFilesRecursive(dir)).filter((f) => f.endsWith(".map"));
|
|
12565
|
+
const prefix = opts.assetPrefix.replace(/^\/+|\/+$/g, "");
|
|
12566
|
+
header("hub sourcemap push", `${project}@${opts.sha.slice(0, 12)}`);
|
|
12567
|
+
meta("dir", dir);
|
|
12568
|
+
if (maps.length === 0) {
|
|
12569
|
+
warn(`no *.map under ${dir} — nothing to push (was the build run with source maps enabled?)`);
|
|
12570
|
+
return;
|
|
12571
|
+
}
|
|
12572
|
+
let pushed = 0;
|
|
12573
|
+
let bytes = 0;
|
|
12574
|
+
const unusable = [];
|
|
12575
|
+
for (const rel of maps) {
|
|
12576
|
+
const reduced = reduceSourceMap(await readFile(join(dir, rel), "utf8"));
|
|
12577
|
+
if (reduced === void 0) {
|
|
12578
|
+
unusable.push(rel);
|
|
12579
|
+
continue;
|
|
12580
|
+
}
|
|
12581
|
+
const body = new TextEncoder().encode(JSON.stringify(reduced));
|
|
12582
|
+
await hub.putSourceMap(project, opts.sha, prefix ? `${prefix}/${rel}` : rel, body);
|
|
12583
|
+
pushed++;
|
|
12584
|
+
bytes += body.length;
|
|
12585
|
+
}
|
|
12586
|
+
if (unusable.length > 0) warn(`${unusable.length} map(s) were not source maps this side can read, e.g. ${unusable[0]}`);
|
|
12587
|
+
await hub.sweepSourceMaps(project);
|
|
12588
|
+
info(`pushed ${pushed} source map(s), ${Math.round(bytes / 1024)} KiB`);
|
|
12589
|
+
}));
|
|
12590
|
+
const sourcemapLs = new Command("ls").description("List the source maps stored for a commit.").requiredOption("--sha <sha>", "The commit to list.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (opts) => {
|
|
12591
|
+
const project = resolveProject(opts);
|
|
12592
|
+
const paths = await connect$2(opts).listSourceMaps(project, opts.sha);
|
|
12593
|
+
header("hub sourcemap ls", `${project}@${opts.sha.slice(0, 12)}`);
|
|
12594
|
+
if (paths.length === 0) {
|
|
12595
|
+
info("no source maps stored for this commit");
|
|
12596
|
+
return;
|
|
12597
|
+
}
|
|
12598
|
+
for (const path of paths) meta("", path);
|
|
12599
|
+
info(`${paths.length} source map(s)`);
|
|
12600
|
+
}));
|
|
12601
|
+
const sourcemapCommand = new Command("sourcemap").description("Manage the source maps coverage reads when a build keeps its maps off the CDN (pushed at deploy time, fetched during `ccqa run --coverage`).").addCommand(sourcemapPush).addCommand(sourcemapLs);
|
|
12310
12602
|
function validatePromptName(rawName) {
|
|
12311
12603
|
if (!isPromptName(rawName)) {
|
|
12312
12604
|
error(`invalid prompt name "${rawName}"`);
|
|
@@ -12595,7 +12887,7 @@ async function runCoverageInspect(opts) {
|
|
|
12595
12887
|
const outside = Object.entries(h.outsideWindowEvents);
|
|
12596
12888
|
if (outside.length > 0) warn(`outside-window events (identity was driven while unclaimed): ${outside.map(([key, count]) => `${key}: ${count}`).join(", ")}`);
|
|
12597
12889
|
}
|
|
12598
|
-
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(coverageCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
|
|
12890
|
+
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(coverageCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(sourcemapCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
|
|
12599
12891
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
12600
12892
|
function isStorageStateShape(state) {
|
|
12601
12893
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -16730,7 +17022,7 @@ async function executeRun(targets, opts) {
|
|
|
16730
17022
|
const liveSpecs = withMode.filter((s) => s.mode === "live");
|
|
16731
17023
|
meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
|
|
16732
17024
|
if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
|
|
16733
|
-
const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox) : void 0;
|
|
17025
|
+
const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedSourceMapReader(hubCtx, deployedSha), hubCtx !== null) : void 0;
|
|
16734
17026
|
if (liveSpecs.length === 0) {
|
|
16735
17027
|
const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
|
|
16736
17028
|
if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
|
|
@@ -16933,13 +17225,7 @@ async function executeRun(targets, opts) {
|
|
|
16933
17225
|
reportDir
|
|
16934
17226
|
};
|
|
16935
17227
|
}
|
|
16936
|
-
|
|
16937
|
-
* Starts the run's coverage measurement before any spec runs: the sink has to
|
|
16938
|
-
* be listening before the first request reaches the application, and the set
|
|
16939
|
-
* of spec ids it will accept is only known once dispatch has resolved. With
|
|
16940
|
-
* an `inbox`, nothing binds — the run streams its events to the hub instead.
|
|
16941
|
-
*/
|
|
16942
|
-
async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
|
|
17228
|
+
async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fetchStoredSourceMap, hubConfigured) {
|
|
16943
17229
|
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");
|
|
16944
17230
|
let session;
|
|
16945
17231
|
try {
|
|
@@ -16949,11 +17235,13 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
|
|
|
16949
17235
|
config,
|
|
16950
17236
|
actors,
|
|
16951
17237
|
specs: dispatch.external.flatMap((g) => g.specs),
|
|
16952
|
-
...inbox ? { inbox } : {}
|
|
17238
|
+
...inbox ? { inbox } : {},
|
|
17239
|
+
...fetchStoredSourceMap ? { fetchStoredSourceMap } : {}
|
|
16953
17240
|
});
|
|
16954
17241
|
} catch (err) {
|
|
16955
17242
|
throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
|
|
16956
17243
|
}
|
|
17244
|
+
if (fetchStoredSourceMap === void 0 && hubConfigured) warn("coverage: source maps pushed for this deploy cannot be read — the deployed commit is unknown here. Pass --hub-profile so the deploy log can be consulted.");
|
|
16957
17245
|
if (inbox !== void 0) meta("coverage", `streaming to hub inbox → ${session.origins.join(", ")}`);
|
|
16958
17246
|
else meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
|
|
16959
17247
|
const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
|
|
@@ -16962,6 +17250,28 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
|
|
|
16962
17250
|
return session;
|
|
16963
17251
|
}
|
|
16964
17252
|
/**
|
|
17253
|
+
* Starts the run's coverage measurement before any spec runs: the sink has to
|
|
17254
|
+
* be listening before the first request reaches the application, and the set
|
|
17255
|
+
* of spec ids it will accept is only known once dispatch has resolved. With
|
|
17256
|
+
* an `inbox`, nothing binds — the run streams its events to the hub instead.
|
|
17257
|
+
*/
|
|
17258
|
+
/**
|
|
17259
|
+
* Needs both a hub and the commit under test: without either there is nothing
|
|
17260
|
+
* to address a stored map by, and reading some other commit's maps would name
|
|
17261
|
+
* the wrong files. See `SourceMapStore`.
|
|
17262
|
+
*/
|
|
17263
|
+
function storedSourceMapReader(hubCtx, deployedSha) {
|
|
17264
|
+
if (hubCtx === null || deployedSha === null) return void 0;
|
|
17265
|
+
const seen = /* @__PURE__ */ new Map();
|
|
17266
|
+
return async (assetPath) => {
|
|
17267
|
+
const cached = seen.get(assetPath);
|
|
17268
|
+
if (cached !== void 0 || seen.has(assetPath)) return cached;
|
|
17269
|
+
const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
|
|
17270
|
+
seen.set(assetPath, map);
|
|
17271
|
+
return map;
|
|
17272
|
+
};
|
|
17273
|
+
}
|
|
17274
|
+
/**
|
|
16965
17275
|
* A run that measured coverage still leaves rows without any — a spec on a
|
|
16966
17276
|
* target that declares no browser, or one that never executed. Saying why keeps
|
|
16967
17277
|
* the reader from reading a blank as "this spec reached nothing".
|
|
@@ -21466,6 +21776,20 @@ function mergeBucket(into, from) {
|
|
|
21466
21776
|
//#endregion
|
|
21467
21777
|
//#region src/hub/core/retention.ts
|
|
21468
21778
|
/**
|
|
21779
|
+
* Drop the source maps of every commit past the newest `maxCommits`.
|
|
21780
|
+
*
|
|
21781
|
+
* Hung off a push for the same reason run retention hangs off a terminal run:
|
|
21782
|
+
* the hub whose disk grows is the one written to constantly and never
|
|
21783
|
+
* restarted. Best-effort — a lost sweep costs disk, while failing the push it
|
|
21784
|
+
* runs beside would cost the deploy.
|
|
21785
|
+
*/
|
|
21786
|
+
async function sweepSourceMapRetention(storage, project, maxCommits) {
|
|
21787
|
+
try {
|
|
21788
|
+
const commits = await storage.sourceMaps.listCommits(project);
|
|
21789
|
+
for (const commit of commits.slice(maxCommits)) await storage.sourceMaps.delete(project, commit);
|
|
21790
|
+
} catch {}
|
|
21791
|
+
}
|
|
21792
|
+
/**
|
|
21469
21793
|
* Drop everything past the newest `maxRuns` of the (project, branch) that
|
|
21470
21794
|
* `run` belongs to, taking each evicted run's artifacts and triage records
|
|
21471
21795
|
* with it.
|
|
@@ -22402,6 +22726,58 @@ function createGetDriftLedgerHandler(storage) {
|
|
|
22402
22726
|
};
|
|
22403
22727
|
}
|
|
22404
22728
|
//#endregion
|
|
22729
|
+
//#region src/hub/api/handlers/sourcemaps.ts
|
|
22730
|
+
/**
|
|
22731
|
+
* A source map is roughly the size of the code it describes, and a single
|
|
22732
|
+
* bundle chunk can be a few MB. Generous, but still a ceiling: a push is many
|
|
22733
|
+
* small requests rather than one archive, so no single body should approach it.
|
|
22734
|
+
*/
|
|
22735
|
+
const MAX_MAP_BYTES = 32 * 1024 * 1024;
|
|
22736
|
+
function createPutSourceMapHandler(storage) {
|
|
22737
|
+
return async (ctx) => {
|
|
22738
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
22739
|
+
const commit = requireSafeSegment(ctx.params.commit, "commit");
|
|
22740
|
+
const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
|
|
22741
|
+
const body = await readBody(ctx.req, MAX_MAP_BYTES);
|
|
22742
|
+
await storage.sourceMaps.put(project, commit, assetPath, body);
|
|
22743
|
+
ctx.res.statusCode = 204;
|
|
22744
|
+
ctx.res.end();
|
|
22745
|
+
};
|
|
22746
|
+
}
|
|
22747
|
+
function createGetSourceMapHandler(storage) {
|
|
22748
|
+
return async (ctx) => {
|
|
22749
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
22750
|
+
const commit = requireSafeSegment(ctx.params.commit, "commit");
|
|
22751
|
+
const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
|
|
22752
|
+
const bytes = await storage.sourceMaps.read(project, commit, assetPath);
|
|
22753
|
+
if (!bytes) throw new HttpError(404, "not_found", `no source map stored for "${assetPath}" at ${commit}`);
|
|
22754
|
+
sendBytes(ctx.res, 200, bytes, "application/json; charset=utf-8");
|
|
22755
|
+
};
|
|
22756
|
+
}
|
|
22757
|
+
/**
|
|
22758
|
+
* Ends a push: everything for this commit has landed, so older commits can go.
|
|
22759
|
+
* Separate from the PUTs because a push is hundreds of them, and sweeping on
|
|
22760
|
+
* each would walk the whole project every time.
|
|
22761
|
+
*/
|
|
22762
|
+
function createSweepSourceMapsHandler(storage) {
|
|
22763
|
+
return async (ctx) => {
|
|
22764
|
+
await sweepSourceMapRetention(storage, requireSafeSegment(ctx.params.project, "project"), 10);
|
|
22765
|
+
ctx.res.statusCode = 204;
|
|
22766
|
+
ctx.res.end();
|
|
22767
|
+
};
|
|
22768
|
+
}
|
|
22769
|
+
function createListSourceMapsHandler(storage) {
|
|
22770
|
+
return async (ctx) => {
|
|
22771
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
22772
|
+
const commit = requireSafeSegment(ctx.params.commit, "commit");
|
|
22773
|
+
sendJson(ctx.res, 200, {
|
|
22774
|
+
project,
|
|
22775
|
+
commit,
|
|
22776
|
+
paths: await storage.sourceMaps.list(project, commit)
|
|
22777
|
+
});
|
|
22778
|
+
};
|
|
22779
|
+
}
|
|
22780
|
+
//#endregion
|
|
22405
22781
|
//#region src/hub/api/handlers/deploys.ts
|
|
22406
22782
|
/** `changedPaths` for a wide refactor can run to tens of thousands of entries. */
|
|
22407
22783
|
const MAX_DEPLOY_BODY_BYTES = 8 * 1024 * 1024;
|
|
@@ -30364,6 +30740,10 @@ function registerRoutes(router, config, queue) {
|
|
|
30364
30740
|
router.get("/api/v1/projects/:project/deploys", createGetDeployLogHandler(storage));
|
|
30365
30741
|
router.get("/api/v1/projects/:project/rerun", createGetRerunHandler(storage));
|
|
30366
30742
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
30743
|
+
router.put("/api/v1/projects/:project/sourcemaps/:commit/*path", createPutSourceMapHandler(storage));
|
|
30744
|
+
router.post("/api/v1/projects/:project/sourcemaps/sweep", createSweepSourceMapsHandler(storage));
|
|
30745
|
+
router.get("/api/v1/projects/:project/sourcemaps/:commit", createListSourceMapsHandler(storage));
|
|
30746
|
+
router.get("/api/v1/projects/:project/sourcemaps/:commit/*path", createGetSourceMapHandler(storage));
|
|
30367
30747
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
30368
30748
|
router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
|
|
30369
30749
|
router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
|
|
@@ -30418,149 +30798,6 @@ function registerRoutes(router, config, queue) {
|
|
|
30418
30798
|
router.get("/api/v1/projects/:project/learning-jobs/:jobId", createGetLearningJobHandler(storage));
|
|
30419
30799
|
}
|
|
30420
30800
|
//#endregion
|
|
30421
|
-
//#region src/hub/core/storage/file/fs-helpers.ts
|
|
30422
|
-
/**
|
|
30423
|
-
* Defense-in-depth for a name this layer joins into a file path. The API's
|
|
30424
|
-
* `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
|
|
30425
|
-
* charset and a length — and stays the rule for what a client may name; this
|
|
30426
|
-
* only refuses traversal, so it also covers callers that never came through
|
|
30427
|
-
* HTTP (tests, a library embedding the hub).
|
|
30428
|
-
*/
|
|
30429
|
-
function assertSafeName(value, label) {
|
|
30430
|
-
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
30431
|
-
}
|
|
30432
|
-
/** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
|
|
30433
|
-
async function readJson(path) {
|
|
30434
|
-
let raw;
|
|
30435
|
-
try {
|
|
30436
|
-
raw = await readFile(path, "utf8");
|
|
30437
|
-
} catch (err) {
|
|
30438
|
-
if (isNotFound(err)) return null;
|
|
30439
|
-
throw err;
|
|
30440
|
-
}
|
|
30441
|
-
try {
|
|
30442
|
-
return JSON.parse(raw);
|
|
30443
|
-
} catch (err) {
|
|
30444
|
-
throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
30445
|
-
}
|
|
30446
|
-
}
|
|
30447
|
-
/**
|
|
30448
|
-
* Write `data` to a temp file in the same directory, then atomically rename it
|
|
30449
|
-
* into place, so a concurrent reader only ever observes the old file or the
|
|
30450
|
-
* fully-written new one — never the empty/partial window a plain
|
|
30451
|
-
* truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
|
|
30452
|
-
* triage file while an API request reads it). The temp file lives in the same
|
|
30453
|
-
* directory as its target to keep the rename on one filesystem, where it is
|
|
30454
|
-
* atomic.
|
|
30455
|
-
*/
|
|
30456
|
-
async function atomicWrite(path, data) {
|
|
30457
|
-
await mkdir(dirname(path), { recursive: true });
|
|
30458
|
-
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
30459
|
-
await writeFile(tmp, data);
|
|
30460
|
-
await rename(tmp, path);
|
|
30461
|
-
}
|
|
30462
|
-
/** Write `value` as pretty JSON, creating parent directories as needed. */
|
|
30463
|
-
async function writeJson(path, value) {
|
|
30464
|
-
await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
|
|
30465
|
-
}
|
|
30466
|
-
/**
|
|
30467
|
-
* Per-path serialization for read-modify-write JSON updates. Two concurrent
|
|
30468
|
-
* `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
|
|
30469
|
-
* on the same run's triage file) would otherwise both read the same starting
|
|
30470
|
-
* state and the second writer's change would silently clobber the first's —
|
|
30471
|
-
* this queues the second call's read until the first's write has landed, so
|
|
30472
|
-
* updates apply in the order they were issued rather than racing. Scoped by
|
|
30473
|
-
* path (a `Map` of chained promises), not globally, so unrelated records still
|
|
30474
|
-
* update concurrently.
|
|
30475
|
-
*/
|
|
30476
|
-
const updateChains = /* @__PURE__ */ new Map();
|
|
30477
|
-
/**
|
|
30478
|
-
* Queue `work` behind whatever is already in flight for `path`. A delete takes
|
|
30479
|
-
* the same chain as the updates it removes: `writeJson` recreates the parent
|
|
30480
|
-
* directory, so an unordered delete would be silently undone by an update that
|
|
30481
|
-
* was already queued.
|
|
30482
|
-
*/
|
|
30483
|
-
async function serialize(path, work) {
|
|
30484
|
-
const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
|
|
30485
|
-
updateChains.set(path, next);
|
|
30486
|
-
try {
|
|
30487
|
-
return await next;
|
|
30488
|
-
} finally {
|
|
30489
|
-
if (updateChains.get(path) === next) updateChains.delete(path);
|
|
30490
|
-
}
|
|
30491
|
-
}
|
|
30492
|
-
async function updateJson(path, mutate) {
|
|
30493
|
-
return await serialize(path, async () => {
|
|
30494
|
-
const updated = mutate(await readJson(path));
|
|
30495
|
-
await writeJson(path, updated);
|
|
30496
|
-
return updated;
|
|
30497
|
-
});
|
|
30498
|
-
}
|
|
30499
|
-
/** Read a raw file, returning `null` when it doesn't exist. */
|
|
30500
|
-
async function readBytesOrNull(path) {
|
|
30501
|
-
try {
|
|
30502
|
-
const buf = await readFile(path);
|
|
30503
|
-
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
30504
|
-
} catch (err) {
|
|
30505
|
-
if (isNotFound(err)) return null;
|
|
30506
|
-
throw err;
|
|
30507
|
-
}
|
|
30508
|
-
}
|
|
30509
|
-
async function writeBytes(path, bytes) {
|
|
30510
|
-
await atomicWrite(path, bytes);
|
|
30511
|
-
}
|
|
30512
|
-
/** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
|
|
30513
|
-
async function listDirOrEmpty(dir) {
|
|
30514
|
-
try {
|
|
30515
|
-
return await readdir(dir);
|
|
30516
|
-
} catch (err) {
|
|
30517
|
-
if (isNotFound(err)) return [];
|
|
30518
|
-
throw err;
|
|
30519
|
-
}
|
|
30520
|
-
}
|
|
30521
|
-
/**
|
|
30522
|
-
* Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
|
|
30523
|
-
* Skips files and dot-entries so stray filesystem litter (e.g. a Finder
|
|
30524
|
-
* `.DS_Store`) never surfaces as a record id or project name.
|
|
30525
|
-
*/
|
|
30526
|
-
async function listSubdirsOrEmpty(dir) {
|
|
30527
|
-
try {
|
|
30528
|
-
return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
30529
|
-
} catch (err) {
|
|
30530
|
-
if (isNotFound(err)) return [];
|
|
30531
|
-
throw err;
|
|
30532
|
-
}
|
|
30533
|
-
}
|
|
30534
|
-
/** Every file path under `dir`, relative to `dir`, posix-separated. */
|
|
30535
|
-
async function listFilesRecursive(dir) {
|
|
30536
|
-
const out = [];
|
|
30537
|
-
async function walk(current) {
|
|
30538
|
-
let entries;
|
|
30539
|
-
try {
|
|
30540
|
-
entries = await readdir(current, { withFileTypes: true });
|
|
30541
|
-
} catch (err) {
|
|
30542
|
-
if (isNotFound(err)) return;
|
|
30543
|
-
throw err;
|
|
30544
|
-
}
|
|
30545
|
-
for (const entry of entries) {
|
|
30546
|
-
const abs = join(current, entry.name);
|
|
30547
|
-
if (entry.isDirectory()) await walk(abs);
|
|
30548
|
-
else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
|
|
30549
|
-
}
|
|
30550
|
-
}
|
|
30551
|
-
await walk(dir);
|
|
30552
|
-
return out;
|
|
30553
|
-
}
|
|
30554
|
-
async function removePath(path) {
|
|
30555
|
-
await rm(path, {
|
|
30556
|
-
recursive: true,
|
|
30557
|
-
force: true
|
|
30558
|
-
});
|
|
30559
|
-
}
|
|
30560
|
-
function isNotFound(err) {
|
|
30561
|
-
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
30562
|
-
}
|
|
30563
|
-
//#endregion
|
|
30564
30801
|
//#region src/hub/core/storage/file/paths.ts
|
|
30565
30802
|
/**
|
|
30566
30803
|
* On-disk layout for the local-directory `HubStorage` backend, all rooted
|
|
@@ -30598,6 +30835,12 @@ function runMetaPath(root, id) {
|
|
|
30598
30835
|
function artifactsRunDir(root, runId) {
|
|
30599
30836
|
return join(root, "artifacts", runId);
|
|
30600
30837
|
}
|
|
30838
|
+
function sourceMapProjectDir(root, project) {
|
|
30839
|
+
return join(root, "sourcemaps", project);
|
|
30840
|
+
}
|
|
30841
|
+
function sourceMapCommitDir(root, project, commit) {
|
|
30842
|
+
return join(sourceMapProjectDir(root, project), commit);
|
|
30843
|
+
}
|
|
30601
30844
|
function jobsDir(root) {
|
|
30602
30845
|
return join(root, "jobs");
|
|
30603
30846
|
}
|
|
@@ -30966,6 +31209,56 @@ function parseLine(rawLine) {
|
|
|
30966
31209
|
};
|
|
30967
31210
|
}
|
|
30968
31211
|
//#endregion
|
|
31212
|
+
//#region src/hub/core/storage/file/sourcemap-store.ts
|
|
31213
|
+
/**
|
|
31214
|
+
* Defense-in-depth: the HTTP layer validates the asset path before it gets
|
|
31215
|
+
* here, but this store joins it onto a directory, so it re-checks rather than
|
|
31216
|
+
* trusting the caller.
|
|
31217
|
+
*/
|
|
31218
|
+
function assertSafeAssetPath(assetPath) {
|
|
31219
|
+
const segments = assetPath.split("/");
|
|
31220
|
+
if (assetPath.length === 0 || assetPath.startsWith("/") || assetPath.includes("\\") || segments.includes("..") || segments.includes(".")) throw new Error("invalid source map path: must be relative, without '.' or '..' segments");
|
|
31221
|
+
}
|
|
31222
|
+
/** Marks when a commit last received a push; see `listCommits`. */
|
|
31223
|
+
const PUSHED_AT = "pushed-at.json";
|
|
31224
|
+
function createFileSourceMapStore(root) {
|
|
31225
|
+
return {
|
|
31226
|
+
async put(project, commit, assetPath, bytes) {
|
|
31227
|
+
assertSafeName(project, "project");
|
|
31228
|
+
assertSafeName(commit, "commit");
|
|
31229
|
+
assertSafeAssetPath(assetPath);
|
|
31230
|
+
const dir = sourceMapCommitDir(root, project, commit);
|
|
31231
|
+
await writeBytes(join(dir, assetPath), bytes);
|
|
31232
|
+
await writeJson(join(dir, PUSHED_AT), { at: Date.now() });
|
|
31233
|
+
},
|
|
31234
|
+
async read(project, commit, assetPath) {
|
|
31235
|
+
assertSafeName(project, "project");
|
|
31236
|
+
assertSafeName(commit, "commit");
|
|
31237
|
+
assertSafeAssetPath(assetPath);
|
|
31238
|
+
return readBytesOrNull(join(sourceMapCommitDir(root, project, commit), assetPath));
|
|
31239
|
+
},
|
|
31240
|
+
async list(project, commit) {
|
|
31241
|
+
assertSafeName(project, "project");
|
|
31242
|
+
assertSafeName(commit, "commit");
|
|
31243
|
+
return (await listFilesRecursive(sourceMapCommitDir(root, project, commit))).filter((file) => file !== PUSHED_AT);
|
|
31244
|
+
},
|
|
31245
|
+
async listCommits(project) {
|
|
31246
|
+
assertSafeName(project, "project");
|
|
31247
|
+
const dir = sourceMapProjectDir(root, project);
|
|
31248
|
+
const commits = await listSubdirsOrEmpty(dir);
|
|
31249
|
+
return (await Promise.all(commits.map(async (commit) => ({
|
|
31250
|
+
commit,
|
|
31251
|
+
at: (await readJson(join(dir, commit, PUSHED_AT)).catch(() => null))?.at ?? 0
|
|
31252
|
+
})))).sort((a, b) => b.at - a.at).map((entry) => entry.commit);
|
|
31253
|
+
},
|
|
31254
|
+
async delete(project, commit) {
|
|
31255
|
+
assertSafeName(project, "project");
|
|
31256
|
+
assertSafeName(commit, "commit");
|
|
31257
|
+
await removePath(sourceMapCommitDir(root, project, commit));
|
|
31258
|
+
}
|
|
31259
|
+
};
|
|
31260
|
+
}
|
|
31261
|
+
//#endregion
|
|
30969
31262
|
//#region src/hub/core/storage/file/deploy-store.ts
|
|
30970
31263
|
function createFileDeployStore(root) {
|
|
30971
31264
|
const readLog = async (project, profile) => await readJson(deployLogPath(root, project, profile)) ?? emptyDeployLog();
|
|
@@ -31409,7 +31702,8 @@ function createFileHubStorage(dataDir) {
|
|
|
31409
31702
|
spend: createFileSpendStore(dataDir),
|
|
31410
31703
|
attestations: createFileAttestationStore(dataDir),
|
|
31411
31704
|
auditDismissals: createFileAuditDismissalStore(dataDir),
|
|
31412
|
-
coverageEvents: createFileCoverageEventStore(dataDir)
|
|
31705
|
+
coverageEvents: createFileCoverageEventStore(dataDir),
|
|
31706
|
+
sourceMaps: createFileSourceMapStore(dataDir)
|
|
31413
31707
|
};
|
|
31414
31708
|
}
|
|
31415
31709
|
//#endregion
|
|
@@ -1159,6 +1159,16 @@ interface HubClient {
|
|
|
1159
1159
|
getCoverage(project: string, q?: {
|
|
1160
1160
|
runId?: string;
|
|
1161
1161
|
}): Promise<HubCoverageAnswer>;
|
|
1162
|
+
/**
|
|
1163
|
+
* Source maps for what a commit deployed, addressed by the asset path the
|
|
1164
|
+
* browser requests. Coverage falls back to these when the build keeps its
|
|
1165
|
+
* maps out of the CDN, which is the usual choice — a map hands out source.
|
|
1166
|
+
*/
|
|
1167
|
+
putSourceMap(project: string, commit: string, assetPath: string, map: Uint8Array): Promise<void>;
|
|
1168
|
+
getSourceMap(project: string, commit: string, assetPath: string): Promise<string | null>;
|
|
1169
|
+
listSourceMaps(project: string, commit: string): Promise<string[]>;
|
|
1170
|
+
/** Ends a push, letting the hub drop the commits it no longer keeps. */
|
|
1171
|
+
sweepSourceMaps(project: string): Promise<void>;
|
|
1162
1172
|
getTriage(id: string): Promise<RunTriage>;
|
|
1163
1173
|
putActualCause(id: string, c: {
|
|
1164
1174
|
feature: string;
|
|
@@ -153,6 +153,28 @@ function createHubClient(opts) {
|
|
|
153
153
|
runId: q.runId
|
|
154
154
|
})}`);
|
|
155
155
|
},
|
|
156
|
+
putSourceMap(project, commit, assetPath, map) {
|
|
157
|
+
return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
|
|
158
|
+
method: "PUT",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: toBodyInit(map)
|
|
161
|
+
}).then(() => void 0);
|
|
162
|
+
},
|
|
163
|
+
async getSourceMap(project, commit, assetPath) {
|
|
164
|
+
try {
|
|
165
|
+
return await text(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof HubApiError && err.status === 404) return null;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
sweepSourceMaps(project) {
|
|
172
|
+
return noBody(`/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/sweep`, "POST");
|
|
173
|
+
},
|
|
174
|
+
async listSourceMaps(project, commit) {
|
|
175
|
+
const { paths } = await json(sourceMapPath(project, commit));
|
|
176
|
+
return paths;
|
|
177
|
+
},
|
|
156
178
|
getTriage(id) {
|
|
157
179
|
return json(`/api/v1/runs/${encodeURIComponent(id)}/triage`);
|
|
158
180
|
},
|
|
@@ -366,6 +388,17 @@ function auditDismissalsPath(project) {
|
|
|
366
388
|
function perspectivesPath(project) {
|
|
367
389
|
return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
|
|
368
390
|
}
|
|
391
|
+
/** `/api/v1/projects/<project>/sourcemaps/<commit>` — the scope a push and a read share. */
|
|
392
|
+
function sourceMapPath(project, commit) {
|
|
393
|
+
return `/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/${encodeURIComponent(commit)}`;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Asset paths keep their separators — the route matches the rest of the URL as
|
|
397
|
+
* one wildcard segment — so only the parts between them are escaped.
|
|
398
|
+
*/
|
|
399
|
+
function encodeAssetPath(assetPath) {
|
|
400
|
+
return assetPath.split("/").map(encodeURIComponent).join("/");
|
|
401
|
+
}
|
|
369
402
|
function queryString(params) {
|
|
370
403
|
const out = new URLSearchParams();
|
|
371
404
|
for (const [key, value] of Object.entries(params)) if (value !== void 0) out.set(key, String(value));
|
package/dist/package.json
CHANGED