ccqa 1.1.1 → 1.2.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 +11 -174
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -429,26 +429,6 @@ async function removeLegacyPerspectivesFiles(cwd) {
|
|
|
429
429
|
for (const path of candidates) if (await unlink(path).then(() => true).catch(() => false)) removed.push(path);
|
|
430
430
|
return removed;
|
|
431
431
|
}
|
|
432
|
-
/**
|
|
433
|
-
* Replace (or insert) the `relatedPaths` key in the spec. Preserves every
|
|
434
|
-
* other top-level field and the entire steps array. Returns the absolute
|
|
435
|
-
* path that was written, or null if the spec file does not exist.
|
|
436
|
-
*/
|
|
437
|
-
async function updateSpecRelatedPaths(featureName, specName, relatedPaths, cwd) {
|
|
438
|
-
const specPath = join(getSpecDir(featureName, specName, cwd), SPEC_FILE);
|
|
439
|
-
const existing = await readFile(specPath, "utf-8").catch(() => null);
|
|
440
|
-
if (existing === null) return null;
|
|
441
|
-
await writeFile(specPath, stringify(stripUndefined({
|
|
442
|
-
...parseTestSpec(existing, specPath),
|
|
443
|
-
relatedPaths: relatedPaths.length > 0 ? relatedPaths : void 0
|
|
444
|
-
}), { lineWidth: 0 }), "utf-8");
|
|
445
|
-
return specPath;
|
|
446
|
-
}
|
|
447
|
-
function stripUndefined(obj) {
|
|
448
|
-
const out = {};
|
|
449
|
-
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
450
|
-
return out;
|
|
451
|
-
}
|
|
452
432
|
const LEGACY_RECORDING_FILES = ["actions.json", "route.md"];
|
|
453
433
|
async function saveRecording(featureName, specName, actions, cwd) {
|
|
454
434
|
const specDir = getSpecDir(featureName, specName, cwd);
|
|
@@ -1891,7 +1871,7 @@ ${formatBlockList(blocks)}
|
|
|
1891
1871
|
|
|
1892
1872
|
1. Read the codebase under cwd to find concrete strings: routes, button labels, aria-labels, page titles, placeholders. Use those exact strings in \`expected\`.
|
|
1893
1873
|
2. If you use \`include:\` steps, verify each \`params\` key matches a declared param of the block (see the Available blocks list above).
|
|
1894
|
-
3. Populate \`relatedPaths\` with
|
|
1874
|
+
3. Populate \`relatedPaths\` with glob patterns pointing at the source files this spec depends on — this is the spec's final, authoritative list (nothing else updates it later, so get it right here): the route/page file for each URL the spec visits, plus the component files (or their parent feature directory) that render the aria-labels, placeholders, or visible texts the spec asserts on. Prefer directory globs (e.g. \`src/features/tasks/**\`) when several files in one area are involved. Confirm each path actually exists via \`Glob\`/\`Read\` before listing it — never guess a path by convention. Be conservative — when unsure whether a real path is relevant, include it rather than omit it (a missing path means a code change silently escapes \`drift --changed\`).
|
|
1895
1875
|
4. Validate the (current or proposed) spec on four axes — emit one issue per finding:
|
|
1896
1876
|
- **assertable**: each \`expected\` can be verified against a string/URL/state that exists in code.
|
|
1897
1877
|
- **blocks**: every \`include\` resolves to a real block; every \`params\` key is declared on that block; every required param is provided.
|
|
@@ -10002,7 +9982,6 @@ function buildTraceSystemPrompt(input) {
|
|
|
10002
9982
|
const stepsText = input.steps.map((step) => `### ${step.id} [${step.source}]
|
|
10003
9983
|
- **Instruction**: ${step.instruction}
|
|
10004
9984
|
- **Expected**: ${step.expected}`).join("\n\n");
|
|
10005
|
-
const relatedPathsBlock = buildRelatedPathsInstruction();
|
|
10006
9985
|
return `You are an expert QA engineer executing a browser E2E test. Execute each step precisely and record every browser action as a structured log line.
|
|
10007
9986
|
|
|
10008
9987
|
## Session
|
|
@@ -10272,6 +10251,9 @@ CCQA_STEP=<step-id> CCQA_ASSERT=url_contains:/dashboard agent-browser --session
|
|
|
10272
10251
|
- A marked command that exits non-zero records nothing — fix the check and re-run it.
|
|
10273
10252
|
- A marker that doesn't match its command (e.g. \`CCQA_ASSERT=1\` on a \`click\`) is ignored with a warning — never do that.
|
|
10274
10253
|
- \`get count\` and \`get url\` exit 0 regardless of what they print. **Read the output**: if the printed count / URL contradicts the marker, the signal did NOT verify — treat it as a failed verification (emit \`ASSERTION_FAILED\` if the step cannot be confirmed another way).
|
|
10254
|
+
- **\`url_contains\` is opt-in, not a habit.** 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.
|
|
10255
|
+
- **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.
|
|
10256
|
+
- **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.
|
|
10275
10257
|
|
|
10276
10258
|
**Fallback text protocol** — ONLY for assert types the markers cannot express
|
|
10277
10259
|
(\`element_enabled\`, \`element_disabled\`, \`element_checked\`,
|
|
@@ -10382,7 +10364,7 @@ RUN_COMPLETED|passed|<summary>
|
|
|
10382
10364
|
RUN_COMPLETED|failed|<summary>
|
|
10383
10365
|
\`\`\`
|
|
10384
10366
|
|
|
10385
|
-
|
|
10367
|
+
## Start
|
|
10386
10368
|
|
|
10387
10369
|
Begin by clearing cookies, then proceed straight to the first step's instruction.
|
|
10388
10370
|
|
|
@@ -10401,130 +10383,12 @@ AB_ACTION|cookies_clear
|
|
|
10401
10383
|
Then emit \`STEP_START|step-01|...\` and execute the first step, prefixing
|
|
10402
10384
|
every one of its agent-browser commands with \`CCQA_STEP=step-01\`. The first
|
|
10403
10385
|
step is responsible for opening the initial URL.
|
|
10404
|
-
`;
|
|
10405
|
-
}
|
|
10406
|
-
function buildRelatedPathsInstruction() {
|
|
10407
|
-
return `## Post-run: emit \`relatedPaths\` block
|
|
10408
|
-
|
|
10409
|
-
After all steps are complete (regardless of pass/fail) and **before** \`RUN_COMPLETED\`, you MUST emit a single \`RELATED_PATHS\` block. The host (not you) writes these paths into the spec — your only job is to emit the block.
|
|
10410
|
-
|
|
10411
|
-
\`relatedPaths\` is a list of glob patterns identifying the source files this spec depends on. CI uses them to decide whether a code change should trigger a drift check for this spec.
|
|
10412
|
-
|
|
10413
|
-
**Do NOT modify any source files.** You have only \`Read\`, \`Grep\`, and \`Glob\` for source inspection. The block you emit is the only output the host uses to update the spec.
|
|
10414
|
-
|
|
10415
|
-
**Inputs to consider:**
|
|
10416
|
-
- The URLs you opened (\`AB_ACTION|open|...\`)
|
|
10417
|
-
- The aria-labels, placeholders, and visible texts you clicked / filled / waited on
|
|
10418
|
-
- The component / page / route files that render those strings (find them with \`Grep\`/\`Read\`/\`Glob\`)
|
|
10419
|
-
|
|
10420
|
-
**How to choose paths:**
|
|
10421
|
-
1. For each URL the test navigates to, locate the route/page file and include it (e.g. \`src/app/tasks/page.tsx\`, \`src/pages/tasks/index.tsx\`).
|
|
10422
|
-
2. For each unique aria-label / placeholder / visible text you interacted with, \`Grep\` the codebase, find the defining component, and include either the file or its parent feature directory.
|
|
10423
|
-
3. Prefer **directory globs** (e.g. \`src/features/tasks/**\`) over individual files when several related components live in the same area. Otherwise list specific files.
|
|
10424
|
-
4. Skip third-party files (\`node_modules/\`), build output (\`dist/\`, \`.next/\`), and generated code.
|
|
10425
|
-
5. Be conservative — false positives (extra paths) are fine; false negatives (missing paths) cause drift to be missed in CI. When unsure whether a path is relevant, include it.
|
|
10426
|
-
6. **Path base:** a path inside this working directory MUST be relative to it — the same base your \`Glob\`/\`Grep\` tools use (\`src/features/tasks/**\`, NOT the repo-root form \`apps/foo/src/features/tasks/**\`). For a file in a monorepo sibling package this spec genuinely depends on, use its **repo-root-relative** path (e.g. \`packages/ui-kit/src/FilePicker.tsx\`); never emit \`../\` forms.
|
|
10427
|
-
7. **Shared / design-system components:** include one only when the spec's checks depend on that component's specific behaviour (e.g. the flow exercises its file picker). Do NOT list generic primitives every screen uses (buttons, tags, dialogs, layout) — they would scope this spec into almost every UI change.
|
|
10428
|
-
|
|
10429
|
-
**Output format (STRICT — one line per path, no leading dashes, no commentary inside the block):**
|
|
10430
|
-
|
|
10431
|
-
\`\`\`
|
|
10432
|
-
RELATED_PATHS_BEGIN
|
|
10433
|
-
src/features/tasks/**
|
|
10434
|
-
src/app/tasks/page.tsx
|
|
10435
|
-
RELATED_PATHS_END
|
|
10436
|
-
\`\`\`
|
|
10437
|
-
|
|
10438
|
-
Emit the block outside any other code block, on its own lines. If the test could not exercise the feature at all (e.g. blocked early), emit the block anyway with whatever paths you can identify; emit \`RELATED_PATHS_BEGIN\` immediately followed by \`RELATED_PATHS_END\` only if you genuinely could not identify any related file.
|
|
10439
|
-
|
|
10440
10386
|
`;
|
|
10441
10387
|
}
|
|
10442
10388
|
function buildTracePrompt(title) {
|
|
10443
10389
|
return `Execute the test for "${title}". Each step's instruction includes the URL or selector context it needs.`;
|
|
10444
10390
|
}
|
|
10445
10391
|
//#endregion
|
|
10446
|
-
//#region src/drift/parse-related-paths.ts
|
|
10447
|
-
/**
|
|
10448
|
-
* Pull a `RELATED_PATHS_BEGIN ... RELATED_PATHS_END` block out of the trace
|
|
10449
|
-
* agent's combined text output. Lines inside the block become entries; blank
|
|
10450
|
-
* lines, bullet markers, and code fences are tolerated. Returns null when the
|
|
10451
|
-
* agent did not emit a block at all so the caller can warn instead of silently
|
|
10452
|
-
* clearing the spec's existing relatedPaths.
|
|
10453
|
-
*/
|
|
10454
|
-
function parseRelatedPathsBlock(text) {
|
|
10455
|
-
const match = text.match(/RELATED_PATHS_BEGIN\s*\n?([\s\S]*?)\n?RELATED_PATHS_END/);
|
|
10456
|
-
if (!match || match[1] === void 0) return null;
|
|
10457
|
-
const seen = /* @__PURE__ */ new Set();
|
|
10458
|
-
const out = [];
|
|
10459
|
-
for (const raw of match[1].split("\n")) {
|
|
10460
|
-
const line = raw.replace(/^```.*$/, "").trim();
|
|
10461
|
-
if (!line) continue;
|
|
10462
|
-
const cleaned = line.replace(/^[-*]\s+/, "").trim();
|
|
10463
|
-
if (!cleaned || seen.has(cleaned)) continue;
|
|
10464
|
-
seen.add(cleaned);
|
|
10465
|
-
out.push(cleaned);
|
|
10466
|
-
}
|
|
10467
|
-
return out;
|
|
10468
|
-
}
|
|
10469
|
-
/**
|
|
10470
|
-
* Normalize parsed entries to the canonical bases the drift matcher expects:
|
|
10471
|
-
* app-relative for files inside the working directory, repo-root-relative
|
|
10472
|
-
* for files outside it. Models sometimes emit an in-app path in repo-root
|
|
10473
|
-
* form (`apps/foo/src/...`) or an outside-package path in `../` form — both
|
|
10474
|
-
* would silently never match.
|
|
10475
|
-
*
|
|
10476
|
-
* `cwdPrefix` is the working directory's path relative to the repo root
|
|
10477
|
-
* ("" when they coincide, unknown → pass `null` to skip prefix handling).
|
|
10478
|
-
*/
|
|
10479
|
-
function normalizeRelatedPaths(paths, cwdPrefix) {
|
|
10480
|
-
const out = [];
|
|
10481
|
-
const warnings = [];
|
|
10482
|
-
const seen = /* @__PURE__ */ new Set();
|
|
10483
|
-
const push = (p) => {
|
|
10484
|
-
if (!seen.has(p)) {
|
|
10485
|
-
seen.add(p);
|
|
10486
|
-
out.push(p);
|
|
10487
|
-
}
|
|
10488
|
-
};
|
|
10489
|
-
for (const raw of paths) {
|
|
10490
|
-
const p = raw.replace(/^\.\//, "");
|
|
10491
|
-
if (cwdPrefix !== null && cwdPrefix !== "" && p.startsWith(`${cwdPrefix}/`)) {
|
|
10492
|
-
push(p.slice(cwdPrefix.length + 1));
|
|
10493
|
-
continue;
|
|
10494
|
-
}
|
|
10495
|
-
if (p.startsWith("../")) {
|
|
10496
|
-
if (cwdPrefix === null || cwdPrefix === "") {
|
|
10497
|
-
warnings.push(`dropped relatedPaths entry "${raw}" — it points outside the repository`);
|
|
10498
|
-
continue;
|
|
10499
|
-
}
|
|
10500
|
-
const segments = [...cwdPrefix.split("/"), ...p.split("/")];
|
|
10501
|
-
const resolved = [];
|
|
10502
|
-
let escaped = false;
|
|
10503
|
-
for (const seg of segments) {
|
|
10504
|
-
if (seg === "" || seg === ".") continue;
|
|
10505
|
-
if (seg === "..") {
|
|
10506
|
-
if (resolved.length === 0) {
|
|
10507
|
-
escaped = true;
|
|
10508
|
-
break;
|
|
10509
|
-
}
|
|
10510
|
-
resolved.pop();
|
|
10511
|
-
} else resolved.push(seg);
|
|
10512
|
-
}
|
|
10513
|
-
if (escaped || resolved.length === 0) {
|
|
10514
|
-
warnings.push(`dropped relatedPaths entry "${raw}" — it points outside the repository`);
|
|
10515
|
-
continue;
|
|
10516
|
-
}
|
|
10517
|
-
push(resolved.join("/"));
|
|
10518
|
-
continue;
|
|
10519
|
-
}
|
|
10520
|
-
push(p);
|
|
10521
|
-
}
|
|
10522
|
-
return {
|
|
10523
|
-
paths: out,
|
|
10524
|
-
warnings
|
|
10525
|
-
};
|
|
10526
|
-
}
|
|
10527
|
-
//#endregion
|
|
10528
10392
|
//#region src/runtime/replay-validate.ts
|
|
10529
10393
|
function isPollCheck(x) {
|
|
10530
10394
|
return x !== null && !Array.isArray(x) && x.kind === "poll-present";
|
|
@@ -11029,7 +10893,11 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11029
10893
|
const expanded = expandSpec(spec, { blocks: await loadAllBlocks(opts.cwd) });
|
|
11030
10894
|
const envScrub = buildSpecEnvScrub(spec, expanded);
|
|
11031
10895
|
const envScrubMap = envScrub.map;
|
|
11032
|
-
if (envScrub.unresolved.length > 0)
|
|
10896
|
+
if (envScrub.unresolved.length > 0) {
|
|
10897
|
+
warn(`spec references env var(s) that are unset at record time: ${envScrub.unresolved.join(", ")}`);
|
|
10898
|
+
warn("their concrete trace-time values (e.g. navigate URLs) will be baked into ir.json instead of kept as ${VAR}, so the recording won't switch across environments.");
|
|
10899
|
+
hint("load these vars before recording — pass --profile <name> (hub) or define them in .env — then re-record.");
|
|
10900
|
+
}
|
|
11033
10901
|
meta("spec", spec.title);
|
|
11034
10902
|
meta("steps", expanded.length);
|
|
11035
10903
|
const includes = collectIncludedBlockNames(spec);
|
|
@@ -11051,7 +10919,6 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11051
10919
|
let overallStatus = "passed";
|
|
11052
10920
|
const traceActions = [];
|
|
11053
10921
|
const stepTracker = createStepTracker();
|
|
11054
|
-
let relatedPathsBuffer = null;
|
|
11055
10922
|
const withStepId = (action, stepId) => {
|
|
11056
10923
|
if (!action) return null;
|
|
11057
10924
|
return stepId ? {
|
|
@@ -11098,13 +10965,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11098
10965
|
if (msg.type !== "assistant") return;
|
|
11099
10966
|
for (const block of msg.message.content ?? []) {
|
|
11100
10967
|
if (block.type !== "text" || !block.text) continue;
|
|
11101
|
-
const
|
|
11102
|
-
if (relatedPathsBuffer !== null) relatedPathsBuffer += text + "\n";
|
|
11103
|
-
else {
|
|
11104
|
-
const idx = text.indexOf("RELATED_PATHS_BEGIN");
|
|
11105
|
-
if (idx !== -1) relatedPathsBuffer = text.slice(idx) + "\n";
|
|
11106
|
-
}
|
|
11107
|
-
for (const line of text.split("\n")) {
|
|
10968
|
+
for (const line of block.text.split("\n")) {
|
|
11108
10969
|
const trimmed = line.trim();
|
|
11109
10970
|
const status = parseStatusLine(line);
|
|
11110
10971
|
if (status) {
|
|
@@ -11129,13 +10990,6 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11129
10990
|
meta("saved", recordingPath);
|
|
11130
10991
|
meta("actions", validatedActions.length);
|
|
11131
10992
|
meta("status", overallStatus.toUpperCase());
|
|
11132
|
-
const parsedRelatedPaths = relatedPathsBuffer !== null ? parseRelatedPathsBlock(relatedPathsBuffer) : null;
|
|
11133
|
-
if (parsedRelatedPaths !== null) {
|
|
11134
|
-
const { paths: relatedPaths, warnings } = normalizeRelatedPaths(parsedRelatedPaths, await resolveCwdPrefix(opts.cwd));
|
|
11135
|
-
for (const w of warnings) warn(w);
|
|
11136
|
-
const written = await updateSpecRelatedPaths(featureName, specName, relatedPaths, opts.cwd);
|
|
11137
|
-
if (written) meta("relatedPaths", `${relatedPaths.length} path(s) written to ${written}`);
|
|
11138
|
-
} else warn("trace did not emit a RELATED_PATHS block; drift --changed cannot scope this spec");
|
|
11139
10993
|
hint(`run 'ccqa generate ${featureName}/${specName}' to generate a test script`);
|
|
11140
10994
|
return {
|
|
11141
10995
|
status: overallStatus,
|
|
@@ -11429,23 +11283,6 @@ function parseStatusLine(text) {
|
|
|
11429
11283
|
}
|
|
11430
11284
|
return null;
|
|
11431
11285
|
}
|
|
11432
|
-
/**
|
|
11433
|
-
* The working directory's path relative to the git repo root ("" when they
|
|
11434
|
-
* coincide), used to pin relatedPaths bases. Null when git is unavailable —
|
|
11435
|
-
* normalization then skips prefix handling rather than guessing.
|
|
11436
|
-
*/
|
|
11437
|
-
async function resolveCwdPrefix(cwd) {
|
|
11438
|
-
const { execFile } = await import("node:child_process");
|
|
11439
|
-
const { promisify } = await import("node:util");
|
|
11440
|
-
const { relative, resolve } = await import("node:path");
|
|
11441
|
-
const dir = resolve(cwd ?? process.cwd());
|
|
11442
|
-
try {
|
|
11443
|
-
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
|
|
11444
|
-
return relative(stdout.trim(), dir);
|
|
11445
|
-
} catch {
|
|
11446
|
-
return null;
|
|
11447
|
-
}
|
|
11448
|
-
}
|
|
11449
11286
|
//#endregion
|
|
11450
11287
|
//#region src/prompts/perspectives.ts
|
|
11451
11288
|
/**
|
package/dist/package.json
CHANGED