ccqa 1.50.1 → 1.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -11126,6 +11126,7 @@ ${stepsText}
11126
11126
  - Judge ONLY this step's \`Expected\` condition. Do not infer pass/fail from steps that have not run yet.
11127
11127
  - If the page shows an error banner, a 404, a login wall, or any blocker that prevents the expected outcome — fail.
11128
11128
  - If the expected outcome is partially satisfied (e.g. the page loaded but the asserted element is missing) — fail, and say which part is missing.
11129
+ - **An \`Expected\` may label its parts.** A \`must:\` line is the assertion — the rule above applies to it. A \`when present:\` line is observed, not required: judge it when it is there, and when it is absent say so in your reason and pass on the \`must:\` lines alone. Every line of a labelled \`Expected\` carries a label; text with no labels at all is all \`must:\`.
11129
11130
  - Pass only when you have *positive* evidence (a successful snapshot, a verified URL, a wait that resolved). "No error shown" is not enough on its own.
11130
11131
  - Do not invent success when blocked: fail honestly with a short reason.
11131
11132
  - **Evidence discipline**: when the assertion target is a specific row / message / banner / URL, scroll it into view (or focus the relevant pane) before letting the step end. The "after" screenshot is captured for you automatically — your job is to make sure that screenshot shows the thing your STEP_RESULT line is talking about.
@@ -12634,12 +12635,21 @@ function isStateSelector(selector) {
12634
12635
  function plainSelectorOf(action) {
12635
12636
  return action.locator && action.index === void 0 ? locatorToSelector(action.locator) : void 0;
12636
12637
  }
12638
+ /**
12639
+ * A `wait` the post-trace validator actually ran and saw fail. Cascade-skipped
12640
+ * actions carry the flag without ever having been attempted, so they are not
12641
+ * evidence of anything and stay.
12642
+ */
12643
+ function isProvenFailedWait(action) {
12644
+ return action.replayUnstable === true && !(action.replayReason ?? "").includes("skipped after a preceding action failed");
12645
+ }
12637
12646
  function actionToLine$1(action) {
12638
12647
  if (isRefSelector(plainSelectorOf(action))) return null;
12639
12648
  if (action.action === "assert" && action.replayUnstable && typeof action.replayReason === "string" && action.replayReason.includes("selector not present")) {
12640
12649
  const sel = plainSelectorOf(action) ?? action.observation ?? "(unknown)";
12641
12650
  return `// [warn] replay-unstable: dropped over-assertion (${action.assert ?? "assert"} ${sel}) — selector not present on replay`;
12642
12651
  }
12652
+ if (action.action === "wait" && isProvenFailedWait(action)) return `// [warn] replay-unstable: dropped wait (${plainSelectorOf(action) ?? "(unknown)"}) — did not resolve on replay`;
12643
12653
  switch (action.action) {
12644
12654
  case "snapshot": return action.observation ? `// ${action.observation}` : null;
12645
12655
  case "wait": {
@@ -13725,6 +13735,7 @@ function actionToLine(action) {
13725
13735
  const sel = action.locator?.value ?? action.observation ?? "(unknown)";
13726
13736
  return `// [warn] replay-unstable: dropped over-assertion (${action.assert ?? "assert"} ${sel}) — selector not present on replay`;
13727
13737
  }
13738
+ if (action.action === "wait" && action.replayUnstable === true && !(action.replayReason ?? "").includes("skipped after a preceding action failed")) return `// [warn] replay-unstable: dropped wait (${action.locator?.value ?? "(unknown)"}) — did not resolve on replay`;
13728
13739
  const locator = action.locator ? locatorToPlaywright(action.locator, action.index) : null;
13729
13740
  const subject = locator !== null && action.index === void 0 ? `${locator}.first()` : locator;
13730
13741
  switch (action.action) {
@@ -14664,32 +14675,42 @@ function requireReportToHubConnection(conn) {
14664
14675
  process.exit(2);
14665
14676
  }
14666
14677
  /**
14667
- * Open the run a `--report-to-hub` command patches into. Failure is fatal: a
14668
- * job that asked to publish and cannot reach the hub has not done what it was
14669
- * told. Thrown rather than exited, so a caller's `finally` still runs (the
14670
- * audit releases its spec claims there). Not retried: a dropped response after
14671
- * the hub committed would leave a second orphan running run.
14678
+ * Open the run a `--report-to-hub` command patches into, and name it on stderr
14679
+ * stdout belongs to the report (`--report-format json`), and a caller that
14680
+ * links to the run needs its id before the command ends (docs/hub.md).
14681
+ *
14682
+ * Failure is fatal: a job that asked to publish and cannot reach the hub has
14683
+ * not done what it was told. Thrown rather than exited, so a caller's
14684
+ * `finally` still runs (the audit releases its spec claims there). Not
14685
+ * retried: a dropped response after the hub committed would leave a second
14686
+ * orphan running run.
14672
14687
  */
14673
- async function openHubRun(kind, conn, cwd, profile) {
14674
- const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
14688
+ async function openHubRun(kind, conn, cwd, opts = {}) {
14689
+ const [branch, gitHead] = await Promise.all([detectBranch(cwd), opts.gitHead !== void 0 ? opts.gitHead : getGitHead(cwd)]);
14690
+ let opened;
14675
14691
  try {
14676
- const run = await conn.hub.openRun({
14692
+ opened = await conn.hub.openRun({
14677
14693
  project: conn.project,
14678
14694
  kind,
14679
14695
  ...branch ? { branch } : {},
14680
- ...profile ? { profile } : {},
14696
+ ...opts.profile ? { profile: opts.profile } : {},
14681
14697
  ...gitHead ? { gitHead } : {},
14698
+ ...opts.deployedSha ? { deployedSha: opts.deployedSha } : {},
14682
14699
  ...ciProvenance()
14683
14700
  });
14684
- return {
14685
- hub: conn.hub,
14686
- kind,
14687
- runId: run.id,
14688
- gitHead
14689
- };
14690
14701
  } catch (err) {
14691
14702
  throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
14692
14703
  }
14704
+ process.stderr.write(`[hub-run] ${JSON.stringify({
14705
+ id: opened.id,
14706
+ kind
14707
+ })}\n`);
14708
+ return {
14709
+ hub: conn.hub,
14710
+ kind,
14711
+ runId: opened.id,
14712
+ gitHead
14713
+ };
14693
14714
  }
14694
14715
  /**
14695
14716
  * Close an open run with its final rows and envelope, answering whether it
@@ -15437,25 +15458,17 @@ async function executeRun(targets, opts) {
15437
15458
  reportDir: null
15438
15459
  };
15439
15460
  }
15440
- const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir, resources);
15441
15461
  let hubRunId = null;
15442
15462
  let hubSink;
15443
15463
  let hubPublishBroken = false;
15444
- if (hubCtx != null && opts.reportToHub) try {
15445
- const branch = await detectBranch(cwd);
15446
- const opened = await hubCtx.hub.openRun({
15447
- project: hubCtx.project,
15448
- ...branch ? { branch } : {},
15464
+ if (hubCtx != null && opts.reportToHub) {
15465
+ const { runId } = await openHubRun("run", hubCtx, cwd, {
15449
15466
  ...opts.hubProfile ? { profile: opts.hubProfile } : {},
15450
- ...git.head ? { gitHead: git.head } : {},
15451
- ...deployedSha ? { deployedSha } : {},
15452
- ...ciProvenance(),
15453
- kind: "run"
15467
+ gitHead: git.head,
15468
+ deployedSha
15454
15469
  });
15455
- hubRunId = opened.id;
15456
- info(`hub: incremental run opened (${opened.id})`);
15457
- await coverage?.linkHubRun(opened.id);
15458
- const runId = opened.id;
15470
+ hubRunId = runId;
15471
+ await coverage?.linkHubRun(runId);
15459
15472
  let hubPatchEverSucceeded = false;
15460
15473
  hubSink = { onUpsert: async (row) => {
15461
15474
  try {
@@ -15473,8 +15486,6 @@ async function executeRun(targets, opts) {
15473
15486
  } else warn(`hub: incremental push failed for ${row.feature}/${row.spec}: ${errMessage(err)}`);
15474
15487
  }
15475
15488
  } };
15476
- } catch (err) {
15477
- throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
15478
15489
  }
15479
15490
  const incrementalReport = createIncrementalReport(reportDir, buildReportEnvelope({
15480
15491
  git,
@@ -15499,6 +15510,7 @@ async function executeRun(targets, opts) {
15499
15510
  warn(`hub: could not finalize interrupted run ${hubRunId}: ${errMessage(err)}`);
15500
15511
  }
15501
15512
  });
15513
+ const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir, resources);
15502
15514
  const externalRows = await runExternalSpecs(dispatch, {
15503
15515
  cwd,
15504
15516
  reportDir,
@@ -16828,6 +16840,8 @@ CCQA_STEP=<step-id> CCQA_ASSERT=url_contains:/dashboard agent-browser --session
16828
16840
 
16829
16841
  - **Assert what the step asks about, nothing else.** The \`expected\` is the contract; anything else you happened to see on the way is not. A nav item, a heading or a greeting that the step never mentions adds no coverage, differs between recordings of the same spec, and is the first thing to break on replay — so the next recording quietly drops it and the test gets weaker without anyone deciding that.
16830
16842
 
16843
+ - **An \`expected\` that labels its parts is recorded down to the \`must:\` lines.** A \`when present:\` line names something the product does not produce on every run, so **nothing that fails on its absence may be recorded for it** — no assert, and no \`wait --text\` either: a recorded wait throws on the next run that legitimately lacks it, which is the same failure an assert would have caused. Look at it with \`snapshot\` or \`get count\` if you want to see it, and record nothing. When the step needs to wait for something asynchronous to arrive, anchor that wait on a \`must:\` signal — waiting on the optional one makes the whole step hinge on it. Every line of a labelled \`expected\` carries a label; text with no labels at all is all \`must:\`.
16844
+
16831
16845
  - **\`url_contains\` is opt-in, not a habit.** The same rule, in the form that gets broken most. Emit it ONLY when a step's own \`expected\` explicitly asks about the URL or path. Do NOT add a \`url_contains\` to "prove" a login succeeded, a page loaded, or a navigation happened — confirm those with \`text_visible\` / \`element_visible\` on something the destination page renders. An unrequested URL assertion adds no coverage the visible-content assert doesn't already give, and is the single most common way an environment gets baked into a test.
16832
16846
  - **When you do assert a URL, the substring may come from ONE place only:** a \`\${VAR}\` URL that *this step's own instruction* opened, written as that \`\${VAR}\` followed by the literal tail after it. If the step opens \`\${APP_URL}/policies\`, assert \`\${APP_URL}/policies\` (or the tail \`/policies\`); the recorder resolves \`\${APP_URL}\` per environment. Never assert on a URL you merely *observed* — login redirects, identity-provider pages, and OAuth callbacks all live on a **different, environment-named origin** than the app, so any substring of them (host, origin, OR path) names the environment.
16833
16847
  - **A leading slash does NOT make a substring safe.** \`/auth-staging\`, \`/env-qa\`, \`/tenant-acme\` look like paths but are environment labels — the first segment of an identity-provider or tenant URL, not an application route. If a substring contains an environment name, a stage token (\`dev\`, \`stg\`, \`prod\`), a tenant/org name, or any fragment of a hostname, it is forbidden even with a leading slash. The only safe path substrings are stable *application* routes off the app's own origin (\`/dashboard\`, \`/policies/new\`), taken from a \`\${VAR}\` you opened — not from a redirect you watched.
@@ -17986,8 +18000,7 @@ async function runRecord(specPath, opts) {
17986
18000
  }
17987
18001
  throw e;
17988
18002
  });
17989
- const push = pushConn ? await openHubRun("record", pushConn, cwdForProfile, opts.hubProfile) : null;
17990
- if (push) info(`hub: record run opened (${push.runId})`);
18003
+ const push = pushConn ? await openHubRun("record", pushConn, cwdForProfile, { ...opts.hubProfile ? { profile: opts.hubProfile } : {} }) : null;
17991
18004
  let recorded = false;
17992
18005
  let sealed = true;
17993
18006
  let tracingStep;
@@ -19065,10 +19078,7 @@ async function runAudit(specPath, opts) {
19065
19078
  let push = null;
19066
19079
  try {
19067
19080
  promptCtx = await resolveAuditPromptContext(opts, cwd);
19068
- if (pushConn) {
19069
- push = await openHubRun("drift", pushConn, cwd, opts.hubProfile);
19070
- if (format === "text") info(`hub: incremental drift run opened (${push.runId})`);
19071
- }
19081
+ if (pushConn) push = await openHubRun("drift", pushConn, cwd, { ...opts.hubProfile ? { profile: opts.hubProfile } : {} });
19072
19082
  results = await analyzeDrift({
19073
19083
  targets,
19074
19084
  cwd,
@@ -20613,7 +20623,7 @@ function createPatchRunHandler(config) {
20613
20623
  sendJson(ctx.res, 200, updated);
20614
20624
  };
20615
20625
  }
20616
- /** GET /api/v1/runs?project&branch&status&kind&since&until&limit */
20626
+ /** GET /api/v1/runs?project&branch&status&kind&ciRunId&since&until&limit */
20617
20627
  function createListRunsHandler(storage) {
20618
20628
  return async (ctx) => {
20619
20629
  const project = ctx.url.searchParams.get("project");
@@ -20621,11 +20631,13 @@ function createListRunsHandler(storage) {
20621
20631
  const status = ctx.url.searchParams.get("status");
20622
20632
  const limitRaw = ctx.url.searchParams.get("limit");
20623
20633
  const kindsRaw = ctx.url.searchParams.get("kind");
20634
+ const ciRunId = boundedParam(ctx.url.searchParams.get("ciRunId"), "ciRunId", 128);
20624
20635
  const runs = await storage.runs.list({
20625
20636
  ...project ? { project } : {},
20626
20637
  ...branch ? { branch } : {},
20627
20638
  ...status ? { status } : {},
20628
20639
  ...kindsRaw ? { kinds: kindsRaw.split(",").map(requireKind) } : {},
20640
+ ...ciRunId ? { ciRunId } : {},
20629
20641
  ...requireWindowParams(ctx.url),
20630
20642
  ...limitRaw ? { limit: Number(limitRaw) } : {}
20631
20643
  });
@@ -24817,12 +24829,15 @@ const CLIENT_JS = `
24817
24829
  // Disconnected: the full-screen login gate is the only thing to show.
24818
24830
  if (!state.token) { showAuthGate(false); return; }
24819
24831
  showAuthGate(true);
24832
+ // A run is addressed by its own id, not through a project, so a link from
24833
+ // outside (a chat message, a CI job) opens it even in a browser that has
24834
+ // never picked one — ahead of the gate below, which would drop the link.
24835
+ var m = parseRunHash(location.hash);
24836
+ if (m) { openRunDetail(m.runId, m.caseKey); return; }
24820
24837
  // With no project chosen yet, the Projects picker is the only useful view —
24821
24838
  // land there (e.g. right after login) instead of an empty Runs list, and
24822
- // gate any deep-linked #/runs or #/secrets to it too.
24839
+ // gate any deep-linked project-scoped view to it too.
24823
24840
  if (location.hash === "#/projects" || !state.project) { openProjects(); return; }
24824
- var m = parseRunHash(location.hash);
24825
- if (m) { openRunDetail(m.runId, m.caseKey); return; }
24826
24841
  if (location.hash === "#/perspectives") { openPerspectives(); return; }
24827
24842
  if (location.hash === "#/coverage") { openCoverage(); return; }
24828
24843
  if (location.hash === "#/secrets") { openSecrets(); return; }
@@ -26406,6 +26421,11 @@ const CLIENT_JS = `
26406
26421
  var runGone = false;
26407
26422
 
26408
26423
  apiFetch("/api/v1/runs/" + encodeURIComponent(runId)).then(function (run) {
26424
+ // Arrived by an outside link: the selected project is whatever the last
26425
+ // visit or the boot auto-select left, which need not be this run's.
26426
+ // Follow the run, so the nav around it answers for the project the page
26427
+ // is actually showing.
26428
+ if (run.project && run.project !== state.project) adoptProject(run.project);
26409
26429
  renderRunHead(run);
26410
26430
  }).catch(function (err) {
26411
26431
  runGone = err.status === 404;
@@ -28483,14 +28503,20 @@ const CLIENT_JS = `
28483
28503
  updateNavGate();
28484
28504
  }
28485
28505
 
28486
- // Scope to a project and land on its Runs view. Shared by the top menu, the
28487
- // Projects grid, and the "new project" flow. Switching project restores the
28488
- // profile last chosen for that project (or "default" if none was saved);
28489
- // the Secrets tab reloads the profile list when opened.
28490
- function chooseProject(p) {
28506
+ // Scope the UI to a project, restoring the profile last chosen for it (or
28507
+ // "default" if none was saved); the Secrets tab reloads the profile list when
28508
+ // opened. Separate from the navigation below because arriving on a run by
28509
+ // link adopts a project without going anywhere.
28510
+ function adoptProject(p) {
28491
28511
  setProject(p);
28492
28512
  storeProject(p);
28493
28513
  setProfile(storedProfileForProject(p) || "default");
28514
+ }
28515
+
28516
+ // Scope to a project and land on its Runs view. Shared by the top menu, the
28517
+ // Projects grid, and the "new project" flow.
28518
+ function chooseProject(p) {
28519
+ adoptProject(p);
28494
28520
  location.hash = "#/runs";
28495
28521
  route();
28496
28522
  }
@@ -30188,7 +30214,7 @@ function createFileRunStore(root) {
30188
30214
  async delete(id) {
30189
30215
  await serialize(runMetaPath(root, id), () => removePath(runDir(root, id)));
30190
30216
  },
30191
- async list({ project, branch, status, kinds, since, until, limit }) {
30217
+ async list({ project, branch, status, kinds, ciRunId, since, until, limit }) {
30192
30218
  const ids = await listSubdirsOrEmpty(runsDir(root));
30193
30219
  const inWindow = windowFilter({
30194
30220
  since,
@@ -30202,6 +30228,7 @@ function createFileRunStore(root) {
30202
30228
  if (branch !== void 0 && run.branch !== branch) continue;
30203
30229
  if (status !== void 0 && run.status !== status) continue;
30204
30230
  if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
30231
+ if (ciRunId !== void 0 && run.ciRunId !== ciRunId) continue;
30205
30232
  if (!inWindow(run.createdAt)) continue;
30206
30233
  runs.push(run);
30207
30234
  }
@@ -1147,9 +1147,10 @@ interface HubClient {
1147
1147
  * `gitHead` stamps the run's commit at open time, so even a run that dies
1148
1148
  * before its final reconcile patch is attributable to a commit.
1149
1149
  *
1150
- * `deployedSha` does the same for the environment's commit. The hub would
1151
- * otherwise read its own deploy-log head here, and the open happens after
1152
- * the deterministic phase, not before the first spec.
1150
+ * `deployedSha` does the same for the environment's commit: the baseline the
1151
+ * caller selected against, which a `--rerun` inherits from an earlier run.
1152
+ * Left unset the hub reads its own deploy-log head, which by the time this
1153
+ * call lands can already name a later deploy.
1153
1154
  */
1154
1155
  openRun(meta: {
1155
1156
  project: string;
@@ -1165,11 +1166,13 @@ interface HubClient {
1165
1166
  }): Promise<Run>;
1166
1167
  /** Add finished spec rows (+ evidence) to a running run; `done` closes it. */
1167
1168
  patchRun(id: string, body: PatchRunRequest): Promise<Run>;
1169
+ /** `ciRunId` answers "which runs did this CI job create", whatever the window. */
1168
1170
  listRuns(q?: {
1169
1171
  project?: string;
1170
1172
  branch?: string;
1171
1173
  status?: RunStatus;
1172
1174
  kind?: Run["kind"];
1175
+ ciRunId?: string;
1173
1176
  limit?: number;
1174
1177
  }): Promise<Run[]>;
1175
1178
  getRun(id: string): Promise<Run>;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.50.1",
3
+ "version": "1.51.1",
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.50.1",
3
+ "version": "1.51.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {