ccqa 1.45.0 → 1.46.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
@@ -223,6 +223,7 @@ const AGENT_BROWSER_TARGET = "agent-browser";
223
223
  */
224
224
  const TestSpecSchema = z.object({
225
225
  title: z.string().min(1),
226
+ disabled: z.boolean().optional(),
226
227
  target: TargetIdSchema.optional(),
227
228
  mode: SpecModeSchema.optional(),
228
229
  session: SessionFieldSchema.optional(),
@@ -647,6 +648,21 @@ async function getTestScript(featureName, specName, cwd) {
647
648
  async function listAllSpecsWithSpecFile(cwd) {
648
649
  return listAllSpecsFilteredBy(SPEC_FILE, cwd);
649
650
  }
651
+ /**
652
+ * The active suite — the tree minus the specs marked `disabled` — which is
653
+ * what `ccqa run` expands "all specs" to. Kept separate from the enumeration
654
+ * above because `serialGroups` and `actors` validate that a spec *name*
655
+ * exists, and a disabled spec is still a name.
656
+ *
657
+ * A spec that will not read or parse stays in: unreadable is not the same as
658
+ * asking to be skipped.
659
+ */
660
+ async function listActiveSpecs(cwd) {
661
+ const all = await listAllSpecsWithSpecFile(cwd);
662
+ return (await Promise.all(all.map(async (ref) => {
663
+ return tryParseTestSpec(await tryReadSpecFile(ref.featureName, ref.specName, cwd))?.disabled === true ? null : ref;
664
+ }))).filter((ref) => ref !== null);
665
+ }
650
666
  async function listAllSpecsFilteredBy(requiredFilename, cwd) {
651
667
  const featuresDir = join(getCcqaDir(cwd), "features");
652
668
  const featureDirs = await readdir(featuresDir).catch(() => []);
@@ -676,13 +692,7 @@ async function resolveSpecTargets(target, enumerateAll, cwd) {
676
692
  specName
677
693
  }];
678
694
  }
679
- return (await listSpecsForFeature(target, cwd)).map((specName) => ({
680
- featureName: target,
681
- specName
682
- }));
683
- }
684
- async function listSpecsForFeature(featureName, cwd) {
685
- return readdir(join(getFeatureDir(featureName, cwd), "test-cases")).catch(() => []);
695
+ return (await listActiveSpecs(cwd)).filter((ref) => ref.featureName === target);
686
696
  }
687
697
  /**
688
698
  * Lists every feature/spec dir under .ccqa/features/, regardless of whether
@@ -4968,6 +4978,7 @@ const PerspectiveSpecSchema = z.object({
4968
4978
  steps: z.array(PerspectiveStepSchema).optional(),
4969
4979
  status: PerspectiveStatusSchema,
4970
4980
  changedAt: z.string().optional(),
4981
+ disabled: z.boolean().optional(),
4971
4982
  note: z.string().optional()
4972
4983
  }).strip();
4973
4984
  const PerspectiveFeatureSchema = z.object({
@@ -7167,6 +7178,7 @@ async function startBrowserCoverage(opts) {
7167
7178
  var Engine = class {
7168
7179
  client;
7169
7180
  connectFn;
7181
+ /** Last known address of the browser; see `refreshWsUrl`. */
7170
7182
  wsUrl;
7171
7183
  reconnects = 0;
7172
7184
  reconnectInFlight = false;
@@ -7285,6 +7297,22 @@ var Engine = class {
7285
7297
  this.stopped = true;
7286
7298
  this.clearTimer();
7287
7299
  }
7300
+ /**
7301
+ * Answers why the address could not be refreshed, so the retry that follows
7302
+ * says whether it is dialling a stale address or a dead browser. Told apart
7303
+ * because one is a driver to fix and the other is a browser to wait for, and
7304
+ * a silent stale address is the shape of the bug this exists to prevent.
7305
+ */
7306
+ async refreshWsUrl() {
7307
+ const ask = this.opts.currentCdpUrl;
7308
+ if (ask === void 0) return void 0;
7309
+ try {
7310
+ this.wsUrl = await browserWebSocketUrl(await ask());
7311
+ return;
7312
+ } catch (error) {
7313
+ return message(error);
7314
+ }
7315
+ }
7288
7316
  async reconnect(initialReason) {
7289
7317
  if (this.reconnectInFlight) return;
7290
7318
  this.reconnectInFlight = true;
@@ -7300,11 +7328,12 @@ var Engine = class {
7300
7328
  this.opts.warn(`browser coverage transport dropped (${reason}); reconnecting in ${delay}ms (${this.reconnects}/${MAX_RECONNECTS})`);
7301
7329
  await new Promise((resolve) => setTimeout(resolve, delay));
7302
7330
  if (this.stopped) return;
7331
+ const stale = await this.refreshWsUrl();
7303
7332
  let fresh;
7304
7333
  try {
7305
7334
  fresh = await this.connectFn(this.wsUrl);
7306
7335
  } catch (error) {
7307
- reason = `reconnect failed: ${message(error)}`;
7336
+ reason = stale ? `reconnect failed: ${message(error)} (address may be stale: ${stale})` : `reconnect failed: ${message(error)}`;
7308
7337
  continue;
7309
7338
  }
7310
7339
  if (this.stopped) {
@@ -7319,6 +7348,7 @@ var Engine = class {
7319
7348
  reason = `reconnect failed: ${message(error)}`;
7320
7349
  continue;
7321
7350
  }
7351
+ this.reconnects = 0;
7322
7352
  if (this.stopped) {
7323
7353
  this.clearTimer();
7324
7354
  fresh.close();
@@ -7749,9 +7779,10 @@ var CoverageSession = class CoverageSession {
7749
7779
  * destinations, the roots — lives here, so the caller only supplies where
7750
7780
  * the browser is.
7751
7781
  */
7752
- armBrowser(ref, cdpUrl, coverageDir) {
7782
+ armBrowser(ref, browser, coverageDir) {
7753
7783
  return startBrowserCoverage({
7754
- cdpUrl,
7784
+ cdpUrl: browser.cdpUrl,
7785
+ currentCdpUrl: browser.currentCdpUrl?.bind(browser),
7755
7786
  specId: specIdFor(this.runId, ref),
7756
7787
  origins: this.origins,
7757
7788
  assetOrigins: this.assetOrigins,
@@ -8257,7 +8288,7 @@ async function runOneSpec$1(ref, opts, blocks) {
8257
8288
  });
8258
8289
  const acquired = browserHandle;
8259
8290
  opts.teardown?.onFinalize(() => acquired.dispose());
8260
- browserEngine = await measurement.collector.armBrowser(ref, browserHandle.cdpUrl, coverageDir);
8291
+ browserEngine = await measurement.collector.armBrowser(ref, browserHandle, coverageDir);
8261
8292
  if (browserHandle.amendCommand) command = browserHandle.amendCommand(command);
8262
8293
  Object.assign(childEnv, browserHandle.env);
8263
8294
  } catch (err) {
@@ -12146,6 +12177,7 @@ async function loadSpecInventory(cwd) {
12146
12177
  const content = await tryReadSpecFile(featureName, specName, cwd);
12147
12178
  if (content === null) return null;
12148
12179
  const spec = parseTestSpec(content, `${featureName}/${specName}/spec.yaml`);
12180
+ if (spec.disabled) return null;
12149
12181
  return {
12150
12182
  featureName,
12151
12183
  specName,
@@ -13884,6 +13916,37 @@ async function acquireAgentBrowserEndpoint(ctx) {
13884
13916
  "about:blank"
13885
13917
  ]);
13886
13918
  if (warm.status !== 0) throw new Error(`could not start the session's browser: ${warm.stderr || warm.stdout}`);
13919
+ return {
13920
+ cdpUrl: askCdpUrl(session),
13921
+ currentCdpUrl: () => askCdpUrlSoon(session),
13922
+ dispose: async () => {}
13923
+ };
13924
+ }
13925
+ /**
13926
+ * The same question on a short leash. Its caller is racing its own backoff
13927
+ * between reconnect attempts, so an answer that arrives after the budget is
13928
+ * spent is worth less than no answer: `spawnAB` would sit through a 30s EAGAIN
13929
+ * retry and a 35s hard timeout, which is the whole reconnect window several
13930
+ * times over.
13931
+ */
13932
+ const CDP_URL_TIMEOUT_MS = 2e3;
13933
+ async function askCdpUrlSoon(session) {
13934
+ const { promisify } = await import("node:util");
13935
+ const { execFile } = await import("node:child_process");
13936
+ const { stdout } = await promisify(execFile)(resolveAgentBrowserBin$1(), [
13937
+ "--session",
13938
+ session,
13939
+ "get",
13940
+ "cdp-url"
13941
+ ], {
13942
+ timeout: CDP_URL_TIMEOUT_MS,
13943
+ encoding: "utf8"
13944
+ });
13945
+ const cdpUrl = stdout.trim().split("\n").pop()?.trim() ?? "";
13946
+ if (!/^wss?:\/\//.test(cdpUrl)) throw new Error(`agent-browser answered no cdp-url for session ${session}`);
13947
+ return cdpUrl;
13948
+ }
13949
+ function askCdpUrl(session) {
13887
13950
  const answer = spawnAB([
13888
13951
  "--session",
13889
13952
  session,
@@ -13892,10 +13955,7 @@ async function acquireAgentBrowserEndpoint(ctx) {
13892
13955
  ]);
13893
13956
  const cdpUrl = answer.stdout.trim().split("\n").pop()?.trim() ?? "";
13894
13957
  if (answer.status !== 0 || !/^wss?:\/\//.test(cdpUrl)) throw new Error(`agent-browser did not answer \`get cdp-url\` for session ${session}: ${answer.stderr || answer.stdout}`);
13895
- return {
13896
- cdpUrl,
13897
- dispose: async () => {}
13898
- };
13958
+ return cdpUrl;
13899
13959
  }
13900
13960
  //#endregion
13901
13961
  //#region src/diagnose/snapshot.ts
@@ -14279,7 +14339,7 @@ async function runOneSpec(args) {
14279
14339
  browserEngine = await opts.coverage.armBrowser({
14280
14340
  featureName,
14281
14341
  specName
14282
- }, handle.cdpUrl, coverageDir);
14342
+ }, handle, coverageDir);
14283
14343
  } catch (err) {
14284
14344
  coverageBroken = errMessage(err);
14285
14345
  warn(`coverage: could not attach to the live browser (${coverageBroken})`);
@@ -17048,7 +17108,7 @@ async function executeRun(targets, opts) {
17048
17108
  customPrompt,
17049
17109
  triageUserPrompt
17050
17110
  };
17051
- const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
17111
+ const enumerateAll = () => listActiveSpecs(cwd);
17052
17112
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
17053
17113
  if (filtering) {
17054
17114
  const before = specs.length;
@@ -21080,13 +21140,15 @@ async function collectTargets(specPath, cwd) {
21080
21140
  specName
21081
21141
  }];
21082
21142
  }
21143
+ const active = new Set((await listActiveSpecs(cwd)).map(specKey));
21083
21144
  const out = [];
21084
21145
  for (const feature of tree) for (const spec of feature.specs) {
21085
21146
  if (!spec.hasSpecFile) continue;
21086
- out.push({
21147
+ const target = {
21087
21148
  featureName: feature.featureName,
21088
21149
  specName: spec.specName
21089
- });
21150
+ };
21151
+ if (active.has(specKey(target))) out.push(target);
21090
21152
  }
21091
21153
  return out;
21092
21154
  }
@@ -21398,7 +21460,7 @@ async function buildSkeleton(tree) {
21398
21460
  const config = await loadProjectConfig(process.cwd()).catch(() => null);
21399
21461
  const changedAt = await readSpecChangedAt(process.cwd());
21400
21462
  return (await Promise.all(tree.map(async (feature) => {
21401
- const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
21463
+ const built = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
21402
21464
  const specYaml = await tryReadSpecFile(feature.featureName, s.specName);
21403
21465
  const meta = readSpecMeta(s.specName, specYaml);
21404
21466
  const plugin = resolveSpecTarget(specYaml, config);
@@ -21410,12 +21472,13 @@ async function buildSkeleton(tree) {
21410
21472
  summary: "",
21411
21473
  ...meta.steps.length > 0 ? { steps: meta.steps } : {},
21412
21474
  status,
21413
- ...lastEdit ? { changedAt: lastEdit } : {}
21475
+ ...lastEdit ? { changedAt: lastEdit } : {},
21476
+ ...meta.disabled ? { disabled: true } : {}
21414
21477
  };
21415
21478
  }));
21416
21479
  return {
21417
21480
  featureName: feature.featureName,
21418
- specs
21481
+ specs: built
21419
21482
  };
21420
21483
  }))).filter((f) => f.specs.length > 0).map((f) => ({
21421
21484
  featureName: f.featureName,
@@ -21478,13 +21541,19 @@ function withoutGeneratedAt(yamlText) {
21478
21541
  function noteKey(featureName, specName) {
21479
21542
  return `${featureName}/${specName}`;
21480
21543
  }
21481
- /** Lenient title/mode read from an already-loaded spec.yaml (null → defaults). */
21544
+ /**
21545
+ * Lenient read of an already-loaded spec.yaml. A file that is missing or will
21546
+ * not parse falls back to `defaults`, which reports the spec as enabled: a
21547
+ * spec nobody could read has not asked to be skipped.
21548
+ */
21482
21549
  function readSpecMeta(specName, specYaml) {
21483
- if (specYaml === null) return {
21550
+ const defaults = {
21484
21551
  title: specName,
21485
21552
  mode: DEFAULT_SPEC_MODE,
21486
- steps: []
21553
+ steps: [],
21554
+ disabled: false
21487
21555
  };
21556
+ if (specYaml === null) return defaults;
21488
21557
  try {
21489
21558
  const parsed = parse(specYaml);
21490
21559
  const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
@@ -21492,14 +21561,11 @@ function readSpecMeta(specName, specYaml) {
21492
21561
  return {
21493
21562
  title,
21494
21563
  mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
21495
- steps: transcribeSteps(parsed.steps)
21564
+ steps: transcribeSteps(parsed.steps),
21565
+ disabled: tryParseTestSpec(specYaml)?.disabled === true
21496
21566
  };
21497
21567
  } catch {
21498
- return {
21499
- title: specName,
21500
- mode: DEFAULT_SPEC_MODE,
21501
- steps: []
21502
- };
21568
+ return defaults;
21503
21569
  }
21504
21570
  }
21505
21571
  /**
@@ -23028,6 +23094,7 @@ function readSpecTargets(doc) {
23028
23094
  for (const spec of specs) {
23029
23095
  const specName = prop(spec, "specName");
23030
23096
  if (typeof specName !== "string") continue;
23097
+ if (prop(spec, "disabled") === true) continue;
23031
23098
  const changedAt = prop(spec, "changedAt");
23032
23099
  out.push({
23033
23100
  key: `${featureName}/${specName}`,
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.45.0",
3
+ "version": "1.46.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.45.0",
3
+ "version": "1.46.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {