@qatonic_innovations/qaios 0.4.1 → 0.5.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.
Files changed (2) hide show
  1. package/dist/index.js +261 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -905,7 +905,19 @@ var QaiosConfig = z.object({
905
905
  // Playwright defaults to `data-testid`; apps using `data-test`,
906
906
  // `data-qa`, etc. must declare it here so generated specs emit a
907
907
  // `test.use({ testIdAttribute })` and actually find their elements.
908
- testIdAttribute: z.string().default("data-testid")
908
+ testIdAttribute: z.string().default("data-testid"),
909
+ // 0.5 DOM grounding: when true (default), `qaios test` captures the
910
+ // live page at `app.baseUrl` before writing and feeds the real DOM
911
+ // to write.web so it generates locators against actual elements
912
+ // instead of guessing from the DesignSpec. Needs the app reachable
913
+ // at generation time; when it isn't, capture fails fast (see
914
+ // groundCaptureTimeoutMs) and falls back to blind generation. Set
915
+ // false to skip capture entirely (also: `qaios test --no-ground`).
916
+ groundLocators: z.boolean().default(true),
917
+ // Hard cap on the headless page-capture before grounding gives up
918
+ // and falls back. Kept tight so a not-yet-running dev server doesn't
919
+ // stall generation — a real capture against a live page is fast.
920
+ groundCaptureTimeoutMs: z.number().int().positive().default(15e3)
909
921
  }).default({}),
910
922
  visual: z.object({
911
923
  viewports: z.array(
@@ -1103,6 +1115,97 @@ var ulidImpl = monotonicFactory();
1103
1115
  function ulid() {
1104
1116
  return ulidImpl();
1105
1117
  }
1118
+ var GET_BY_TESTID_RE = /getByTestId\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
1119
+ var GET_BY_ROLE_RE = /getByRole\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{[^}]*?name\s*:\s*['"`]([^'"`]+)['"`][^}]*\})?\s*\)/g;
1120
+ var GET_BY_TEXT_RE = /getByText\(\s*['"`]([^'"`]+)['"`]/g;
1121
+ function countOccurrences(hay, needle) {
1122
+ if (needle.length === 0) return 0;
1123
+ let count = 0;
1124
+ let from = 0;
1125
+ for (; ; ) {
1126
+ const idx = hay.indexOf(needle, from);
1127
+ if (idx < 0) break;
1128
+ count += 1;
1129
+ from = idx + needle.length;
1130
+ }
1131
+ return count;
1132
+ }
1133
+ function collectA11yNodes(tree, out) {
1134
+ if (tree === null || typeof tree !== "object") return;
1135
+ const node = tree;
1136
+ if (typeof node.role === "string") {
1137
+ out.push({ role: node.role, name: typeof node.name === "string" ? node.name : "" });
1138
+ }
1139
+ if (Array.isArray(node.children)) {
1140
+ for (const child of node.children) collectA11yNodes(child, out);
1141
+ }
1142
+ }
1143
+ function verifyLocatorsAgainstSnapshot(specContents, snapshot) {
1144
+ if (snapshot === void 0) {
1145
+ return { okCount: 0, problems: [], uncheckedCount: 0 };
1146
+ }
1147
+ const html = snapshot.html ?? "";
1148
+ const truncated = html.includes("<!-- truncated -->");
1149
+ const a11yNodes = [];
1150
+ collectA11yNodes(snapshot.accessibilityTree, a11yNodes);
1151
+ const problems = [];
1152
+ let okCount = 0;
1153
+ let uncheckedCount = 0;
1154
+ for (const m of specContents.matchAll(GET_BY_TESTID_RE)) {
1155
+ const id = m[1];
1156
+ const valueHits = countOccurrences(html, `"${id}"`) + countOccurrences(html, `'${id}'`);
1157
+ if (valueHits === 0) {
1158
+ if (truncated) uncheckedCount += 1;
1159
+ else problems.push({ expression: m[0], problem: "missing", count: 0, kind: "testid" });
1160
+ } else if (valueHits > 1) {
1161
+ problems.push({ expression: m[0], problem: "ambiguous", count: valueHits, kind: "testid" });
1162
+ } else {
1163
+ okCount += 1;
1164
+ }
1165
+ }
1166
+ if (a11yNodes.length > 0) {
1167
+ for (const m of specContents.matchAll(GET_BY_ROLE_RE)) {
1168
+ const role = m[1];
1169
+ const name = m[2];
1170
+ const roleMatches = a11yNodes.filter((n) => n.role === role);
1171
+ if (roleMatches.length === 0) {
1172
+ problems.push({ expression: m[0], problem: "missing", count: 0, kind: "role" });
1173
+ } else if (name !== void 0) {
1174
+ const exact = roleMatches.filter((n) => n.name === name);
1175
+ if (exact.length > 1) {
1176
+ problems.push({
1177
+ expression: m[0],
1178
+ problem: "ambiguous",
1179
+ count: exact.length,
1180
+ kind: "role"
1181
+ });
1182
+ } else {
1183
+ okCount += 1;
1184
+ }
1185
+ } else {
1186
+ uncheckedCount += 1;
1187
+ }
1188
+ }
1189
+ } else {
1190
+ for (const _m of specContents.matchAll(GET_BY_ROLE_RE)) uncheckedCount += 1;
1191
+ }
1192
+ for (const m of specContents.matchAll(GET_BY_TEXT_RE)) {
1193
+ const text = m[1];
1194
+ const hits = countOccurrences(html, text);
1195
+ if (hits === 0) {
1196
+ if (truncated) uncheckedCount += 1;
1197
+ else problems.push({ expression: m[0], problem: "missing", count: 0, kind: "text" });
1198
+ } else {
1199
+ okCount += 1;
1200
+ }
1201
+ }
1202
+ return { okCount, problems, uncheckedCount };
1203
+ }
1204
+ function summarizeLocatorProblems(v) {
1205
+ return v.problems.map(
1206
+ (p) => p.problem === "missing" ? `${p.expression} \u2014 matched no element on the captured page` : `${p.expression} \u2014 matched ${p.count} elements (ambiguous; narrow it instead of using .nth/.first)`
1207
+ );
1208
+ }
1106
1209
  var QaiosError = class extends Error {
1107
1210
  code;
1108
1211
  detail;
@@ -5237,7 +5340,7 @@ function extractLocatorToken(expr) {
5237
5340
  }
5238
5341
  return null;
5239
5342
  }
5240
- function countOccurrences(haystack, needle) {
5343
+ function countOccurrences2(haystack, needle) {
5241
5344
  if (needle.length === 0) return 0;
5242
5345
  let n = 0;
5243
5346
  let i = haystack.indexOf(needle);
@@ -5281,7 +5384,7 @@ function applyLocatorFix(opts) {
5281
5384
  const cwd = opts.cwd ?? process.cwd();
5282
5385
  const absPath = path15.isAbsolute(opts.testFile) ? opts.testFile : path15.resolve(cwd, opts.testFile);
5283
5386
  const oldContents = readFileSync(absPath, "utf-8");
5284
- const occurrences = countOccurrences(oldContents, opts.fix.oldLocator);
5387
+ const occurrences = countOccurrences2(oldContents, opts.fix.oldLocator);
5285
5388
  if (occurrences === 0) {
5286
5389
  return {
5287
5390
  applied: false,
@@ -6059,12 +6162,65 @@ var writing = async (deps) => {
6059
6162
  const appContext = buildAppContext(deps.args);
6060
6163
  appContext["pomMode"] = typeof deps.args["pomMode"] === "string" && (deps.args["pomMode"] === "off" || deps.args["pomMode"] === "auto" || deps.args["pomMode"] === "on") ? deps.args["pomMode"] : "auto";
6061
6164
  appContext["framework"] = "playwright";
6165
+ const groundUrl = baseUrlFromArgs(deps.args);
6166
+ const groundDisabled = deps.args["noGround"] === true;
6167
+ const groundTimeoutMs = typeof deps.args["groundCaptureTimeoutMs"] === "number" ? deps.args["groundCaptureTimeoutMs"] : 15e3;
6168
+ if (!groundDisabled && groundUrl.length > 0 && deps.capturePageSnapshot !== void 0) {
6169
+ const cwd = typeof deps.args["cwd"] === "string" ? deps.args["cwd"] : process.cwd();
6170
+ try {
6171
+ const snapshot2 = await deps.capturePageSnapshot({
6172
+ url: groundUrl,
6173
+ cwd,
6174
+ timeoutMs: groundTimeoutMs
6175
+ });
6176
+ if (snapshot2 !== null) {
6177
+ appContext["pageSnapshot"] = snapshot2;
6178
+ auditGrounding(deps, "grounding_captured", { url: groundUrl });
6179
+ } else {
6180
+ auditGrounding(deps, "grounding_skipped", {
6181
+ reason: "page capture returned no snapshot",
6182
+ url: groundUrl
6183
+ });
6184
+ }
6185
+ } catch (err) {
6186
+ auditGrounding(deps, "grounding_skipped", {
6187
+ reason: `page capture failed: ${err.message}`,
6188
+ url: groundUrl
6189
+ });
6190
+ }
6191
+ }
6062
6192
  const input = {
6063
6193
  designSpec: designOutput?.["designSpec"] ?? null,
6064
6194
  appContext,
6065
6195
  testDir: typeof deps.args["testDir"] === "string" ? deps.args["testDir"] : "tests"
6066
6196
  };
6067
- return runSkill(deps, skillId, input, "writing_gate");
6197
+ const result = await runSkill(deps, skillId, input, "writing_gate");
6198
+ const snapshot = appContext["pageSnapshot"];
6199
+ if (snapshot !== void 0) {
6200
+ try {
6201
+ const out = result.skillExecution?.output;
6202
+ const files = Array.isArray(out?.files) ? out.files : [];
6203
+ const specText = files.filter((f) => f.kind === "spec" && typeof f.contents === "string").map((f) => f.contents).join("\n");
6204
+ const verification = verifyLocatorsAgainstSnapshot(
6205
+ specText,
6206
+ snapshot
6207
+ );
6208
+ if (verification.problems.length > 0) {
6209
+ const summary = summarizeLocatorProblems(verification);
6210
+ auditGrounding(deps, "grounding_warning", {
6211
+ problems: summary,
6212
+ okCount: verification.okCount,
6213
+ uncheckedCount: verification.uncheckedCount
6214
+ });
6215
+ result.warnings = [
6216
+ `${summary.length} generated locator(s) don't cleanly match the live page:`,
6217
+ ...summary.map((s) => ` \u2022 ${s}`)
6218
+ ];
6219
+ }
6220
+ } catch {
6221
+ }
6222
+ }
6223
+ return result;
6068
6224
  };
6069
6225
  var writingGate = async (deps) => {
6070
6226
  const writeKey2 = Object.keys(deps.prior).find((k) => k.startsWith("write."));
@@ -6441,6 +6597,16 @@ function auditHeal(deps, kind, payload) {
6441
6597
  actor: "system"
6442
6598
  });
6443
6599
  }
6600
+ function auditGrounding(deps, kind, payload) {
6601
+ deps.auditLogger.append({
6602
+ workflowId: deps.workflow.id,
6603
+ phase: "WRITING",
6604
+ skillId: "write.web",
6605
+ event: "skill.completed",
6606
+ payload: { kind: `write.${kind}`, ...payload },
6607
+ actor: "system"
6608
+ });
6609
+ }
6444
6610
  function auditDefect(deps, kind, payload) {
6445
6611
  deps.auditLogger.append({
6446
6612
  workflowId: deps.workflow.id,
@@ -6583,7 +6749,11 @@ var Orchestrator = class {
6583
6749
  this.costTracker = new CostTracker(deps.storage.db);
6584
6750
  this.gatesRepo = new GatesRepository(deps.storage.db);
6585
6751
  this.costCap = deps.costCap === null ? null : deps.costCap ?? DEFAULT_COST_CAP;
6586
- this.capturePageSnapshot = deps.capturePageSnapshot ?? ((o) => capturePageSnapshot({ url: o.url, cwd: o.cwd }));
6752
+ this.capturePageSnapshot = deps.capturePageSnapshot ?? ((o) => capturePageSnapshot({
6753
+ url: o.url,
6754
+ cwd: o.cwd,
6755
+ ...o.timeoutMs !== void 0 ? { timeoutMs: o.timeoutMs } : {}
6756
+ }));
6587
6757
  }
6588
6758
  deps;
6589
6759
  workflowsRepo;
@@ -6977,7 +7147,8 @@ var Orchestrator = class {
6977
7147
  workflowId,
6978
7148
  skillId: result.skillExecution.skillId,
6979
7149
  durationMs: Date.now() - skillStart,
6980
- output: result.skillExecution.output
7150
+ output: result.skillExecution.output,
7151
+ ...result.warnings !== void 0 && result.warnings.length > 0 ? { warnings: result.warnings } : {}
6981
7152
  });
6982
7153
  }
6983
7154
  if (this.costCap !== null) {
@@ -10045,10 +10216,23 @@ var WriteWebInput = z.object({
10045
10216
  // Defaults to Playwright's own default (`data-testid`); when the app
10046
10217
  // uses something else (e.g. `data-test`), the generated spec must set
10047
10218
  // it via `test.use({ testIdAttribute })` or getByTestId finds nothing.
10048
- testIdAttribute: z.string().default("data-testid")
10219
+ testIdAttribute: z.string().default("data-testid"),
10220
+ // 0.5: DOM grounding. When the orchestrator has a baseUrl, it
10221
+ // captures the live page (same headless snapshot heal.locator uses)
10222
+ // and threads it in here so the writer generates locators against
10223
+ // REAL elements instead of guessing from the DesignSpec's text hints.
10224
+ // Optional: API tests and no-baseUrl runs omit it and the writer
10225
+ // behaves byte-for-byte as before. Same shape as HealLocatorInput's
10226
+ // pageSnapshot so both paths share the capture infrastructure.
10227
+ pageSnapshot: z.object({
10228
+ url: z.string(),
10229
+ html: z.string().optional(),
10230
+ accessibilityTree: z.unknown().optional()
10231
+ }).optional()
10049
10232
  }),
10050
10233
  testDir: z.string().default("tests")
10051
10234
  });
10235
+ var WRITE_WEB_HTML_MAX_CHARS = 5e4;
10052
10236
  var HEADER_PLACEHOLDERS = {
10053
10237
  workflowId: "<ulid will be filled by runtime>",
10054
10238
  generatedAt: "<iso-8601 will be filled by runtime>",
@@ -10090,7 +10274,39 @@ function detectDisallowedImports(contents) {
10090
10274
  }
10091
10275
  return [...specifiers].sort();
10092
10276
  }
10093
- function buildSystemPrompt12(projectMemory, locatorPreference, knownSelectors, testIdAttribute) {
10277
+ function truncateHtmlForWriting(html, maxChars = WRITE_WEB_HTML_MAX_CHARS) {
10278
+ if (html.length <= maxChars) return html;
10279
+ return html.slice(0, maxChars) + "\n<!-- truncated -->";
10280
+ }
10281
+ function buildPageGroundingBlock(snapshot) {
10282
+ if (snapshot === void 0) return "";
10283
+ const html = snapshot.html ? truncateHtmlForWriting(snapshot.html) : "(no HTML captured)";
10284
+ const a11y = snapshot.accessibilityTree !== void 0 ? JSON.stringify(snapshot.accessibilityTree, null, 2) : "(no accessibility tree captured)";
10285
+ return `
10286
+
10287
+ REAL PAGE ELEMENTS (captured live from ${snapshot.url}):
10288
+ The HTML and accessibility tree below are the ACTUAL target page. Treat them
10289
+ as ground truth, overriding any locator hint in the DesignSpec that conflicts.
10290
+ - Generate every locator against an element you can SEE here. Do NOT invent
10291
+ test-ids, roles, or text that do not appear below.
10292
+ - Prefer a locator that matches EXACTLY ONE element. If the obvious locator
10293
+ matches several (e.g. several elements share the same role+name), narrow it
10294
+ with a containing region (\`getByRole('navigation').getByRole('link', \u2026)\`),
10295
+ a more specific accessible name, or \`.filter({ hasText })\` \u2014 do NOT reach
10296
+ for \`.nth(n)\` or \`.first()\` to paper over an ambiguous match.
10297
+ - If a DesignSpec step needs an element that genuinely is NOT on this page,
10298
+ emit \`test.fixme(...)\` with a comment and add the gap to \`qaiosMdSuggestions\`
10299
+ \u2014 do not guess a locator.
10300
+
10301
+ Page HTML (truncated):
10302
+ \`\`\`html
10303
+ ${html}
10304
+ \`\`\`
10305
+
10306
+ Accessibility tree:
10307
+ ${a11y}`;
10308
+ }
10309
+ function buildSystemPrompt12(projectMemory, locatorPreference, knownSelectors, testIdAttribute, pageSnapshot) {
10094
10310
  const knownSelectorsRendered = knownSelectors.length === 0 ? "(none)" : knownSelectors.join(", ");
10095
10311
  const testIdBlock = !testIdAttribute || testIdAttribute === "data-testid" ? "" : `
10096
10312
  - TEST-ID ATTRIBUTE: this project's test ids use the \`${testIdAttribute}\` attribute,
@@ -10147,7 +10363,7 @@ Project context:
10147
10363
  ${projectMemory}
10148
10364
 
10149
10365
  Locator preference order: ${locatorPreference.join(", ")}
10150
- Known selectors (use these where applicable): ${knownSelectorsRendered}
10366
+ Known selectors (use these where applicable): ${knownSelectorsRendered}${buildPageGroundingBlock(pageSnapshot)}
10151
10367
 
10152
10368
  Output via the \`submit\` tool.`;
10153
10369
  }
@@ -10175,7 +10391,8 @@ var writeWebSkill = {
10175
10391
  ctx.projectMemory,
10176
10392
  input.appContext.locatorPreference,
10177
10393
  input.appContext.knownSelectors,
10178
- input.appContext.testIdAttribute
10394
+ input.appContext.testIdAttribute,
10395
+ input.appContext.pageSnapshot
10179
10396
  ),
10180
10397
  userPrompt: (_ctx, input) => buildUserPrompt12(input),
10181
10398
  confidenceFn: (output, ctx) => {
@@ -10187,7 +10404,21 @@ var writeWebSkill = {
10187
10404
  const scan = scanGeneratedTests(output.files, designedIds);
10188
10405
  const assertionsPresent = scan.specFileCount === 0 || scan.specFilesWithoutAssertions.length === 0 ? 1 : 0.6;
10189
10406
  const idsInContents = scan.unreferencedScenarioIds.length === 0 ? 1 : 0.7;
10190
- return Math.max(0, Math.min(1, reported * noSilentDrops * assertionsPresent * idsInContents));
10407
+ const snapshot = ctx.input.appContext.pageSnapshot;
10408
+ let locatorsVerified = 1;
10409
+ if (snapshot !== void 0) {
10410
+ const allSpecText = output.files.filter((f) => f.kind === "spec").map((f) => f.contents).join("\n");
10411
+ const v = verifyLocatorsAgainstSnapshot(allSpecText, snapshot);
10412
+ const checked = v.okCount + v.problems.length;
10413
+ if (checked > 0 && v.problems.length > 0) {
10414
+ const badRatio = v.problems.length / checked;
10415
+ locatorsVerified = Math.max(0.5, 1 - badRatio);
10416
+ }
10417
+ }
10418
+ return Math.max(
10419
+ 0,
10420
+ Math.min(1, reported * noSilentDrops * assertionsPresent * idsInContents * locatorsVerified)
10421
+ );
10191
10422
  }
10192
10423
  };
10193
10424
  var WriteApiInput = z.object({
@@ -14014,7 +14245,9 @@ function riskHotspotsFromMemory2(mem) {
14014
14245
  var GLYPH6 = {
14015
14246
  ok: "\u2713",
14016
14247
  failed: "\u2717",
14017
- blocked: "\u23F8"};
14248
+ blocked: "\u23F8",
14249
+ warn: "\u26A0"
14250
+ };
14018
14251
  function renderTextLine4(event) {
14019
14252
  switch (event.kind) {
14020
14253
  case "workflow_started":
@@ -14024,8 +14257,13 @@ function renderTextLine4(event) {
14024
14257
  const seconds = (event.durationMs / 1e3).toFixed(1);
14025
14258
  const conf = readConfidence2(event.output);
14026
14259
  const confSuffix = conf !== null ? ` (confidence ${conf.toFixed(2)})` : "";
14027
- return `\u25B8 ${event.skillId.padEnd(22)} ${GLYPH6.ok} ${seconds}s${confSuffix}
14260
+ let line = `\u25B8 ${event.skillId.padEnd(22)} ${GLYPH6.ok} ${seconds}s${confSuffix}
14028
14261
  `;
14262
+ if (Array.isArray(event.warnings) && event.warnings.length > 0) {
14263
+ line += ` ${GLYPH6.warn} ${event.warnings.join("\n ")}
14264
+ `;
14265
+ }
14266
+ return line;
14029
14267
  }
14030
14268
  case "gate_evaluated":
14031
14269
  return event.decision === "pass" ? `\u25B8 gate.${event.gateType.padEnd(16)} ${GLYPH6.ok} passed
@@ -14172,6 +14410,11 @@ ${epSummary}`;
14172
14410
  riskHotspots: riskHotspotsFromMemory2(memory),
14173
14411
  knownSelectors: []
14174
14412
  };
14413
+ if (testType === "web") {
14414
+ const groundEnabled = opts.noGround === true ? false : testCfg?.groundLocators ?? true;
14415
+ if (!groundEnabled) args["noGround"] = true;
14416
+ args["groundCaptureTimeoutMs"] = testCfg?.groundCaptureTimeoutMs ?? 15e3;
14417
+ }
14175
14418
  if (testType === "api" && parsedSpec !== null) {
14176
14419
  args["apiContext"] = {
14177
14420
  baseUrl: parsedSpec.baseUrl,
@@ -14465,7 +14708,10 @@ function buildProgram() {
14465
14708
  "--scope <scope>",
14466
14709
  "design scope: minimal (~3 scenarios) | standard (default) | exhaustive (~15-25 scenarios)",
14467
14710
  "standard"
14468
- ).option("--run", "execute tests immediately after generating").option("--out <path>", "override default test output directory").option("--feature-name <name>", "override auto-detected feature name").option("--yes", "auto-approve any blocking gate for this run (non-interactive)").option(
14711
+ ).option("--run", "execute tests immediately after generating").option(
14712
+ "--no-ground",
14713
+ "skip DOM grounding (do not capture the live page before writing locators)"
14714
+ ).option("--out <path>", "override default test output directory").option("--feature-name <name>", "override auto-detected feature name").option("--yes", "auto-approve any blocking gate for this run (non-interactive)").option(
14469
14715
  "--workflow <id>",
14470
14716
  "resume a prior workflow interrupted mid-run instead of starting fresh"
14471
14717
  ).action(async (description, cmdOpts, command) => {
@@ -14481,6 +14727,7 @@ function buildProgram() {
14481
14727
  };
14482
14728
  if (typeof cmdOpts["apiSpec"] === "string") opts.apiSpec = cmdOpts["apiSpec"];
14483
14729
  if (typeof cmdOpts["baseUrl"] === "string") opts.baseUrl = cmdOpts["baseUrl"];
14730
+ if (cmdOpts["ground"] === false) opts.noGround = true;
14484
14731
  if (typeof cmdOpts["out"] === "string") opts.out = cmdOpts["out"];
14485
14732
  if (typeof cmdOpts["featureName"] === "string") opts.featureName = cmdOpts["featureName"];
14486
14733
  if (cmdOpts["yes"] === true) opts.yes = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qatonic_innovations/qaios",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "AI QA engineer in your terminal — designs, writes, runs, heals, and explores tests for web UI and APIs with audit-grade traceability.",
6
6
  "license": "MIT",