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 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 === void 0) return void 0;
6543
- let json = decodeInlineSourceMap(reference);
6544
- if (json === void 0) {
6545
- let target;
6546
- try {
6547
- target = new URL(reference, script.url).toString();
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 map = parseSourceMap(json);
6555
- if (map === void 0) return void 0;
6556
- return prepareSourceMap(map, source);
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.map((origin) => resolveEnvRefs(origin));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.8",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.8",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {