@qatonic_innovations/qaios 0.5.3 → 0.6.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.
Files changed (49) hide show
  1. package/README.md +40 -32
  2. package/dist/GateReview-MDXNA542.js +127 -0
  3. package/dist/SnapshotReview-7TPWX62T.js +137 -0
  4. package/dist/a11y/scripts/axe-runner.mjs +8 -1
  5. package/dist/a11y-D2ESBYWX.js +360 -0
  6. package/dist/chunk-4D352FXP.js +185 -0
  7. package/dist/chunk-AW4GQLOM.js +67 -0
  8. package/dist/chunk-BZOQ4NNA.js +533 -0
  9. package/dist/chunk-DYAAVZGS.js +27 -0
  10. package/dist/chunk-EAMUH7ZB.js +23 -0
  11. package/dist/chunk-EEFQVLZX.js +264 -0
  12. package/dist/chunk-HZDJWR25.js +1816 -0
  13. package/dist/chunk-JCVLIA63.js +63 -0
  14. package/dist/chunk-OOI2XRPM.js +231 -0
  15. package/dist/chunk-PGTYCHBV.js +18 -0
  16. package/dist/chunk-RDZE7TZG.js +17 -0
  17. package/dist/chunk-UOPD5XI2.js +298 -0
  18. package/dist/chunk-VMTUIR5O.js +14611 -0
  19. package/dist/chunk-VSSX6F42.js +27 -0
  20. package/dist/chunk-WUHORHPY.js +42 -0
  21. package/dist/chunk-ZL6W6TH2.js +2104 -0
  22. package/dist/config-NRRBZZ35.js +657 -0
  23. package/dist/config-schema.d.ts +20 -0
  24. package/dist/config-schema.js +3 -0
  25. package/dist/dist-QCMTQH2D.js +2 -0
  26. package/dist/doctor-4LUAESSS.js +942 -0
  27. package/dist/evidence-L2GG45YA.js +224 -0
  28. package/dist/explore-VJIQQBEG.js +425 -0
  29. package/dist/fix-2QC6ZIX6.js +804 -0
  30. package/dist/healing/scripts/capture-page.mjs +22 -3
  31. package/dist/history-UBEWZE2G.js +506 -0
  32. package/dist/index.d.ts +45 -1
  33. package/dist/index.js +826 -14869
  34. package/dist/init-64R4KSLV.js +204 -0
  35. package/dist/manifest-seal-7HRI6C5U.js +5 -0
  36. package/dist/mcp-FE4XUYRP.js +272 -0
  37. package/dist/migrations/0004_review_records.sql +56 -0
  38. package/dist/review-HX34P7FQ.js +499 -0
  39. package/dist/run-XFKBCGNB.js +312 -0
  40. package/dist/schemas/config-layer-v1.schema.json +395 -0
  41. package/dist/schemas/config-v1.schema.json +398 -0
  42. package/dist/snapshot-UTPP6UNI.js +443 -0
  43. package/dist/snapshot-review-IBN3QV7G.js +7 -0
  44. package/dist/test-WXHGG4GY.js +10 -0
  45. package/dist/validation/worker.d.ts +2 -0
  46. package/dist/validation/worker.js +686 -0
  47. package/dist/visual/scripts/capture-screenshot.mjs +3 -0
  48. package/dist/xray-HK6PIENO.js +674 -0
  49. package/package.json +8 -8
package/README.md CHANGED
@@ -10,7 +10,7 @@ QAIOS acts like a QA engineer on your team. Point it at a feature description or
10
10
 
11
11
  **Mental model: Claude Code, but for QA work instead of feature coding.**
12
12
 
13
- > **Status: pre-1.0 (v0.5.2 released).** Core flows — `init`, `doctor`, test design
13
+ > **Status: pre-1.0** ([changelog](https://github.com/qatonic/qaios/blob/main/CHANGELOG.md)). Core flows — `init`, `doctor`, test design
14
14
  > & generation, Playwright execution, self-healing, accessibility, and the
15
15
  > audit log — work today. Expect rough edges and please
16
16
  > [report issues](https://github.com/qatonic/qaios/issues). After installing,
@@ -35,10 +35,11 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
35
35
 
36
36
  ### Requirements
37
37
 
38
- - **Node.js 22 LTS or newer.** QAIOS uses Node's **built-in** SQLite
39
- (`node:sqlite`, stable in Node 22) for its local audit log — **no native
40
- module to compile**, so `npm install` is just a download. (On Node < 22 the
41
- built-in isn't available; upgrade Node.)
38
+ - **Node.js 22.13 or newer.** QAIOS uses Node's **built-in** SQLite
39
+ (`node:sqlite`) for its local audit log — **no native module to compile**,
40
+ so `npm install` is just a download. `node:sqlite` loads without a flag from
41
+ Node 22.13; before that it isn't available, so upgrade Node. Node 22 still
42
+ labels it experimental; QAIOS hides that one warning.
42
43
  - An **LLM provider API key.** QAIOS uses **Anthropic** by default — get a key
43
44
  at [console.anthropic.com](https://console.anthropic.com/settings/keys) and
44
45
  export it:
@@ -63,7 +64,7 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
63
64
 
64
65
  QAIOS has **no native dependencies** — it uses Node's built-in SQLite — so
65
66
  install is friction-free. If `qaios init` reports a SQLite/`node:sqlite` error,
66
- your Node is too old: run `node -v` and upgrade to **Node 22 LTS or newer**.
67
+ your Node is too old: run `node -v` and upgrade to **Node 22.13 or newer**.
67
68
 
68
69
  `qaios doctor` will tell you exactly which check failed and what to run.
69
70
 
@@ -107,11 +108,11 @@ Cost: 11,403 tokens · $0.06 (3 of 20 LLM calls used)
107
108
  | Command | What it does |
108
109
  | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
109
110
  | `qaios init [path]` | Bootstrap a project: create `.qaios/`, `QAIOS.md`, run migrations. |
110
- | `qaios doctor` | Health-check your environment (Node, API key, Playwright, config). `--json` for CI. |
111
+ | `qaios doctor` | Health-check your environment. Use `--deep` for read-only Chromium/MCP probes and `--json` for stable CI diagnostics. |
111
112
  | `qaios test <description>` | Design + generate tests from natural language. `--type api` for OpenAPI. `--run` to execute. `--yes` to auto-approve gates. |
112
113
  | `qaios run [pattern]` | Execute Playwright tests, classify failures (real / flaky / locator drift), auto-heal locator breaks, file defects. |
113
114
  | `qaios fix [target]` | Self-heal a broken locator. `--last-failure` to target the most recent failure, `--auto` to apply, `--dry-run` to preview. |
114
- | `qaios explore <url>` | Time-boxed exploratory session against a live URL. `--duration <seconds>`, `--focus "<hint>"`. |
115
+ | `qaios explore <url>` | Charter + unverified hypotheses from one page snapshot (no browser session). `--focus`, `--file-hypotheses`. |
115
116
  | `qaios a11y <url>` | Deterministic axe-core accessibility scan + AI triage. `--wcag-level A\|AA\|AAA`. Exits 1 on real violations (CI-gateable). |
116
117
  | `qaios snapshot capture\|check\|review` | Visual regression: capture baselines, diff, and triage AI-classified diffs. |
117
118
  | `qaios review` | Walk pending human-review gates and approve/reject; resumes the workflow. |
@@ -136,7 +137,7 @@ qaios fix tests/auth/login.spec.ts --last-failure --dry-run
136
137
  # Accessibility scan, gate CI on real WCAG violations
137
138
  qaios a11y https://staging.myapp.com --wcag-level AA
138
139
 
139
- # Exploratory testing with a focus hint
140
+ # Exploration charter + unverified hypotheses (no browser session)
140
141
  qaios explore https://staging.myapp.com --duration 900 --focus "checkout flow"
141
142
 
142
143
  # Visual regression
@@ -165,11 +166,13 @@ testing:
165
166
  framework: playwright
166
167
  testDir: tests
167
168
  testIdAttribute: data-testid # e.g. set to 'data-test' for some apps
169
+ # fixtures: tests/fixtures.ts # optional: your `base.extend(...)` test; generated specs use it
168
170
  locatorPreference: [data-testid, role, text]
169
171
  gates:
170
172
  autoExpireOnTimeout: abort # abort | auto_approve | auto_reject
171
173
  defects:
172
174
  target: stdout # stdout | github | jira
175
+ reproduceBeforeFiling: true # GitHub/Jira only get failures that fail again on a re-run
173
176
  ```
174
177
 
175
178
  `qaios config show` prints the resolved config; `qaios config set <key> <value>` validates against the schema before writing. **API keys are never written to config** — they come from environment variables.
@@ -189,18 +192,20 @@ llm:
189
192
  # OpenAI
190
193
  llm:
191
194
  provider: openai # reads OPENAI_API_KEY
192
- model: gpt-4o # optional; default gpt-4o (try gpt-4o-mini for lower cost)
195
+ model: gpt-6.1-sol # optional; the default (try gpt-6-luna for lower cost)
193
196
  ```
194
197
 
195
198
  ```bash
196
199
  export OPENAI_API_KEY=sk-...
197
- qaios doctor # confirms the configured provider's key is reachable
200
+ qaios doctor # confirms the provider accepts your key (add --offline to skip the call)
198
201
  ```
199
202
 
200
203
  The rest of QAIOS is provider-blind — every command (`test`, `run`, `fix`,
201
204
  `explore`, `a11y`, …) works identically on either. Structured output uses each
202
- provider's native guaranteed-schema mode (Anthropic forced tool-use / OpenAI
203
- strict function calling), so generated artifacts stay schema-valid. You can
205
+ provider's native structured-output mode (a `submit` tool on Anthropic; strict
206
+ function calling on OpenAI), and every answer is validated against the skill's
207
+ schema, so generated artifacts stay schema-valid. GPT-5 and later models are
208
+ called through OpenAI's Responses API; GPT-4 models through Chat Completions. You can
204
209
  override the key's env-var name with `llm.apiKeyEnv` if needed.
205
210
 
206
211
  #### Picking a model
@@ -209,21 +214,22 @@ override the key's env-var name with `llm.apiKeyEnv` if needed.
209
214
  that provider's API accepts works**, not just the defaults. When `llm.model` is
210
215
  omitted, QAIOS uses a sensible default per provider:
211
216
 
212
- | Provider | Default | Other examples you can set |
213
- | ----------- | ------------------- | ------------------------------------------------- |
214
- | `anthropic` | `claude-sonnet-4-6` | `claude-opus-4-7`, `claude-haiku-4-5-20251001`, … |
215
- | `openai` | `gpt-4o` | `gpt-4o-mini` (cheaper), `gpt-4.1`, … |
217
+ | Provider | Default ($ per 1M tokens in / out) | Other examples you can set |
218
+ | ----------- | ---------------------------------- | --------------------------------------------------------------- |
219
+ | `anthropic` | `claude-sonnet-5-5` ($2 / $10) | `claude-opus-5-5` ($4 / $20), `claude-haiku-5-5` (cheapest), … |
220
+ | `openai` | `gpt-6.1-sol` ($2 / $10) | `gpt-6-astra` ($10 / $50), `gpt-6-luna` (cheapest), `gpt-4o`, … |
216
221
 
217
222
  ```yaml
218
223
  llm:
219
224
  provider: openai
220
- model: gpt-4o-mini # any OpenAI model id
225
+ model: gpt-6-luna # any OpenAI model id
221
226
  ```
222
227
 
223
228
  Two things to know about non-default models:
224
229
 
225
230
  - **Cost tracking.** QAIOS knows exact pricing for a built-in set of models
226
- (Sonnet/Opus/Haiku and gpt-4o/4o-mini/4.1/4.1-mini). A model **outside** that
231
+ (Claude Sonnet/Opus/Haiku/Fable 4.x-5.x; GPT-6 Sol/Astra/Luna and
232
+ gpt-4o/4o-mini/4.1/4.1-mini). A model **outside** that
227
233
  set still runs fine, but the USD figure in the audit log is approximate — it
228
234
  bills at a default rate and prints a one-time `no pricing for model …`
229
235
  warning. The per-workflow call/cost **cap still applies** regardless.
@@ -259,18 +265,18 @@ qaios config set mode TRUST
259
265
 
260
266
  Every command exits with a stable, documented code so you can gate pipelines:
261
267
 
262
- | Code | Meaning | Example |
263
- | ----- | ------------------------------- | ----------------------------------------------------------------------- |
264
- | `0` | Success | tests generated; `doctor` all-green; `a11y` clean |
265
- | `1` | User error / actionable failure | bad flag; `a11y` found real violations; `run` had failing tests |
266
- | `2` | Gate blocked (informational) | a workflow paused for human review — run `qaios review` or pass `--yes` |
267
- | `3` | Tool/dependency error | Playwright or axe not installed; SQLite binding missing |
268
- | `4` | LLM error | rate limit, timeout, or invalid API key |
269
- | `5` | Internal error | unexpected crash (re-run with `--debug` for a stack trace) |
270
- | `130` | Cancelled | you pressed Ctrl+C |
268
+ | Code | Meaning | Example |
269
+ | ----- | ------------------------------- | ------------------------------------------------------------------------ |
270
+ | `0` | Success | tests generated; `doctor` all-green; `fix --dry-run` found a valid patch |
271
+ | `1` | User error / actionable failure | bad flag; `run` had failing tests; `fix` found no valid fix |
272
+ | `2` | Gate blocked (informational) | paused for human review — run `qaios review`, or `fix` without `--auto` |
273
+ | `3` | Tool/dependency error | Playwright or axe not installed; SQLite binding missing |
274
+ | `4` | LLM error | rate limit, timeout, or invalid API key |
275
+ | `5` | Internal error | unexpected crash (re-run with `--debug` for a stack trace) |
276
+ | `130` | Cancelled | you pressed Ctrl+C |
271
277
 
272
- `qaios doctor --json` and `--json` on most commands emit machine-readable
273
- output for CI consumption.
278
+ Every command supports protocol-v1 machine output: `--json` emits one terminal object,
279
+ while `--jsonl` emits ordered events followed by one terminal object.
274
280
 
275
281
  ---
276
282
 
@@ -288,8 +294,10 @@ output for CI consumption.
288
294
 
289
295
  ## Status & roadmap
290
296
 
291
- **v0.5.2 is released.** v0.5.3 focuses on truth and consistency before the
292
- trustworthy-alpha, agent-runtime, quality-intelligence, ecosystem, and stable-OS phases.
297
+ Releases and their changes are listed in the
298
+ [changelog](https://github.com/qatonic/qaios/blob/main/CHANGELOG.md). Current work focuses on
299
+ truth and consistency before the trustworthy-alpha, agent-runtime, quality-intelligence,
300
+ ecosystem, and stable-OS phases.
293
301
  The [active roadmap](https://github.com/qatonic/qaios/blob/main/QAIOS_PRODUCT_AND_ENGINEERING_ROADMAP.md)
294
302
  is validation-gated, not calendar-driven.
295
303
 
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ import { useApp, useInput, Box, Text } from 'ink';
3
+ import { useState } from 'react';
4
+ import { jsx, jsxs } from 'react/jsx-runtime';
5
+
6
+ function dispatchGateKeypress(input, ctrl = false) {
7
+ const key = input.toLowerCase();
8
+ if (key === "q" || ctrl && key === "c") return { kind: "quit" };
9
+ if (key === "a") return { kind: "decide", action: "approve" };
10
+ if (key === "r") return { kind: "decide", action: "reject" };
11
+ if (key === "s") return { kind: "decide", action: "skip" };
12
+ return { kind: "ignore" };
13
+ }
14
+ function severityColor(level) {
15
+ switch (level) {
16
+ case "CRITICAL":
17
+ return "red";
18
+ case "HIGH":
19
+ return "yellow";
20
+ case "MEDIUM":
21
+ return "cyan";
22
+ default:
23
+ return "gray";
24
+ }
25
+ }
26
+ function GatePreview({ gate }) {
27
+ const payload = gate.payload;
28
+ const summaryLines = [];
29
+ if (gate.gateType === "design_review") {
30
+ if (payload["featureName"]) summaryLines.push(`feature: ${String(payload["featureName"])}`);
31
+ if (payload["suiteName"]) summaryLines.push(`suite: ${String(payload["suiteName"])}`);
32
+ if (payload["scenarioCount"])
33
+ summaryLines.push(`scenarios: ${String(payload["scenarioCount"])}`);
34
+ } else if (gate.gateType === "writing_review") {
35
+ if (payload["fileCount"]) summaryLines.push(`files: ${String(payload["fileCount"])}`);
36
+ if (payload["qaiosMdSuggestionCount"]) {
37
+ summaryLines.push(`review notes: ${String(payload["qaiosMdSuggestionCount"])}`);
38
+ }
39
+ } else if (gate.gateType === "healing_review") {
40
+ if (payload["file"]) summaryLines.push(`file: ${String(payload["file"])}`);
41
+ if (payload["oldLocator"]) summaryLines.push(`- ${String(payload["oldLocator"])}`);
42
+ if (payload["newLocator"]) summaryLines.push(`+ ${String(payload["newLocator"])}`);
43
+ } else if (gate.gateType === "visual_review") {
44
+ if (payload["target"]) summaryLines.push(`screenshot: ${String(payload["target"])}`);
45
+ if (payload["changed"]) summaryLines.push(`changed: ${String(payload["changed"])}`);
46
+ if (payload["verdict"]) summaryLines.push(`classifier: ${String(payload["verdict"])}`);
47
+ if (payload["currentImage"]) summaryLines.push(`new image: ${String(payload["currentImage"])}`);
48
+ if (payload["diffImage"]) summaryLines.push(`diff image: ${String(payload["diffImage"])}`);
49
+ }
50
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [
51
+ summaryLines.map((l) => /* @__PURE__ */ jsx(Text, { color: "white", children: l }, l)),
52
+ payload["reasoning"] !== void 0 && payload["reasoning"] !== null && String(payload["reasoning"]).length > 0 && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
53
+ String(payload["reasoning"]).slice(0, 240),
54
+ String(payload["reasoning"]).length > 240 ? "\u2026" : ""
55
+ ] }) })
56
+ ] });
57
+ }
58
+ function GateReview(props) {
59
+ const { gates, onDecide, onExit } = props;
60
+ const [index, setIndex] = useState(0);
61
+ const { exit } = useApp();
62
+ const current = gates[index];
63
+ useInput((input, key) => {
64
+ if (current === void 0) return;
65
+ const intent = dispatchGateKeypress(input, key.ctrl);
66
+ if (intent.kind === "quit") {
67
+ onExit("quit");
68
+ exit();
69
+ return;
70
+ }
71
+ if (intent.kind === "decide") {
72
+ onDecide(current, intent.action);
73
+ next();
74
+ }
75
+ });
76
+ function next() {
77
+ if (index >= gates.length - 1) {
78
+ onExit("completed");
79
+ exit();
80
+ return;
81
+ }
82
+ setIndex(index + 1);
83
+ }
84
+ if (gates.length === 0) {
85
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713 No pending gates." }) });
86
+ }
87
+ if (current === void 0) {
88
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsxs(Text, { children: [
89
+ "Reviewing gates\u2026 (",
90
+ index + 1,
91
+ " of ",
92
+ gates.length,
93
+ ")"
94
+ ] }) });
95
+ }
96
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
97
+ /* @__PURE__ */ jsxs(Box, { children: [
98
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "\u26A0 Review: " }),
99
+ /* @__PURE__ */ jsx(Text, { bold: true, color: severityColor(current.riskLevel), children: current.gateType }),
100
+ /* @__PURE__ */ jsxs(Text, { children: [
101
+ " ",
102
+ "(",
103
+ index + 1,
104
+ "/",
105
+ gates.length,
106
+ ")"
107
+ ] })
108
+ ] }),
109
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { children: [
110
+ "Skill: ",
111
+ /* @__PURE__ */ jsx(Text, { bold: true, children: current.skillId }),
112
+ " \xB7 Risk:",
113
+ " ",
114
+ /* @__PURE__ */ jsx(Text, { color: severityColor(current.riskLevel), children: current.riskLevel }),
115
+ " \xB7 Confidence:",
116
+ " ",
117
+ current.confidence.toFixed(2),
118
+ " \xB7 Action: ",
119
+ /* @__PURE__ */ jsx(Text, { bold: true, children: current.requiredAction })
120
+ ] }) }),
121
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { children: "Preview:" }) }),
122
+ /* @__PURE__ */ jsx(GatePreview, { gate: current }),
123
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[a]pprove \xB7 [r]eject \xB7 [s]kip \xB7 [q]uit" }) })
124
+ ] });
125
+ }
126
+
127
+ export { GateReview, dispatchGateKeypress };
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { useApp, useInput, Box, Text } from 'ink';
3
+ import { useState } from 'react';
4
+ import { jsx, jsxs } from 'react/jsx-runtime';
5
+
6
+ function dispatchKeypress(input) {
7
+ switch (input.toLowerCase()) {
8
+ case "a":
9
+ return { kind: "decide", action: "approve" };
10
+ case "r":
11
+ return { kind: "decide", action: "reject" };
12
+ case "s":
13
+ return { kind: "decide", action: "skip" };
14
+ case "q":
15
+ return { kind: "quit" };
16
+ default:
17
+ return { kind: "ignore" };
18
+ }
19
+ }
20
+ function formatPct(p) {
21
+ if (p >= 1) return `${p.toFixed(2)}%`;
22
+ if (p > 0) return `${p.toFixed(3)}%`;
23
+ return `0%`;
24
+ }
25
+ function ClassificationView({
26
+ classification
27
+ }) {
28
+ if (classification === null) {
29
+ return /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "(unclassified \u2014 visual.diff was skipped or failed)" });
30
+ }
31
+ const color = classification.verdict === "regression" ? "red" : classification.verdict === "intentional" ? "green" : classification.verdict === "noise" ? "gray" : "yellow";
32
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
33
+ /* @__PURE__ */ jsxs(Text, { children: [
34
+ "Verdict:",
35
+ " ",
36
+ /* @__PURE__ */ jsx(Text, { color, bold: true, children: classification.verdict }),
37
+ " ",
38
+ "(confidence ",
39
+ classification.confidence.toFixed(2),
40
+ ")"
41
+ ] }),
42
+ /* @__PURE__ */ jsxs(Text, { children: [
43
+ "Suggested: ",
44
+ classification.suggestedAction
45
+ ] }),
46
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { wrap: "wrap", children: classification.reasoning }) }),
47
+ classification.affectedAreas.length > 0 ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
48
+ "Affected: ",
49
+ classification.affectedAreas.join(", ")
50
+ ] }) }) : null
51
+ ] });
52
+ }
53
+ function SnapshotReview({
54
+ items,
55
+ onDecide,
56
+ onExit
57
+ }) {
58
+ const { exit } = useApp();
59
+ const [index, setIndex] = useState(0);
60
+ const [exited, setExited] = useState(false);
61
+ const finish = (reason) => {
62
+ if (exited) return;
63
+ setExited(true);
64
+ onExit(reason);
65
+ exit();
66
+ };
67
+ useInput((input) => {
68
+ if (exited || items.length === 0 || index >= items.length) return;
69
+ const intent = dispatchKeypress(input);
70
+ if (intent.kind === "ignore") return;
71
+ if (intent.kind === "quit") {
72
+ finish("quit");
73
+ return;
74
+ }
75
+ onDecide(items[index], intent.action);
76
+ const next = index + 1;
77
+ if (next >= items.length) finish("completed");
78
+ else setIndex(next);
79
+ });
80
+ if (items.length === 0) {
81
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsx(Text, { children: "(no pending visual diffs)" }) });
82
+ }
83
+ if (exited || index >= items.length) {
84
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsx(Text, { children: "Review complete." }) });
85
+ }
86
+ const current = items[index];
87
+ const total = items.length;
88
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
89
+ /* @__PURE__ */ jsxs(Box, { children: [
90
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "QAIOS visual review" }),
91
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` (${index + 1} / ${total})` })
92
+ ] }),
93
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
94
+ /* @__PURE__ */ jsxs(Text, { children: [
95
+ /* @__PURE__ */ jsx(Text, { bold: true, children: current.baseline.name }),
96
+ /* @__PURE__ */ jsx(Text, { children: `@${current.baseline.viewport}` })
97
+ ] }),
98
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
99
+ "route: ",
100
+ current.baseline.route
101
+ ] }),
102
+ /* @__PURE__ */ jsxs(Text, { children: [
103
+ formatPct(current.diff.percentageChanged),
104
+ " pixels changed (",
105
+ current.diff.pixelsChanged,
106
+ " ",
107
+ "px)"
108
+ ] }),
109
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
110
+ "baseline: ",
111
+ current.baseline.imagePath
112
+ ] }),
113
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
114
+ "current: ",
115
+ current.diff.currentImagePath
116
+ ] }),
117
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
118
+ "diff: ",
119
+ current.diff.diffImagePath
120
+ ] })
121
+ ] }),
122
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx(ClassificationView, { classification: current.diff.classification ?? null }) }),
123
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
124
+ "[",
125
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "a" }),
126
+ "]pprove \u2192 update baseline \xB7 [",
127
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "r" }),
128
+ "]eject \u2192 keep baseline \xB7 [",
129
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "s" }),
130
+ "]kip \xB7 [",
131
+ /* @__PURE__ */ jsx(Text, { children: "q" }),
132
+ "]uit"
133
+ ] }) })
134
+ ] });
135
+ }
136
+
137
+ export { SnapshotReview, dispatchKeypress };
@@ -78,6 +78,12 @@ try {
78
78
  const context = await browser.newContext();
79
79
  const page = await context.newPage();
80
80
  await page.goto(url, { timeout: timeoutMs, waitUntil });
81
+ // Single-page apps render after DOMContentLoaded (live test: an Angular shop
82
+ // scanned as an empty shell, 0 violations vs 27). Let the network settle,
83
+ // best effort and bounded.
84
+ if (waitUntil !== 'networkidle') {
85
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
86
+ }
81
87
 
82
88
  const builder = new AxeBuilder({ page });
83
89
  if (tags.length > 0) builder.withTags(tags);
@@ -99,7 +105,8 @@ try {
99
105
  ...tag(results.incomplete, 'incomplete'),
100
106
  ];
101
107
 
102
- writeFileSync(out, JSON.stringify({ url, findings }), 'utf-8');
108
+ // finalUrl: where navigation ended (a login redirect is reported, not hidden).
109
+ writeFileSync(out, JSON.stringify({ url, finalUrl: page.url(), findings }), 'utf-8');
103
110
  } catch (err) {
104
111
  process.stderr.write(`axe-runner: ${err?.message ?? err}\n`);
105
112
  process.exit(4);