ghost-qa 1.3.1 ā 2.0.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/README.md +88 -32
- package/SKILL.md +83 -80
- package/dist/bin/ghost-qa.d.ts +11 -3
- package/dist/bin/ghost-qa.d.ts.map +1 -1
- package/dist/bin/ghost-qa.js +498 -405
- package/dist/bin/ghost-qa.js.map +1 -1
- package/dist/src/engine/audit-runner.d.ts +6 -5
- package/dist/src/engine/audit-runner.d.ts.map +1 -1
- package/dist/src/engine/audit-runner.js +117 -60
- package/dist/src/engine/audit-runner.js.map +1 -1
- package/dist/src/engine/browser-session.d.ts +3 -3
- package/dist/src/engine/browser-session.d.ts.map +1 -1
- package/dist/src/engine/browser-session.js +18 -7
- package/dist/src/engine/browser-session.js.map +1 -1
- package/dist/src/engine/compare.d.ts +37 -0
- package/dist/src/engine/compare.d.ts.map +1 -0
- package/dist/src/engine/compare.js +63 -0
- package/dist/src/engine/compare.js.map +1 -0
- package/dist/src/engine/health-score.d.ts +14 -0
- package/dist/src/engine/health-score.d.ts.map +1 -0
- package/dist/src/engine/health-score.js +74 -0
- package/dist/src/engine/health-score.js.map +1 -0
- package/dist/src/engine/version.d.ts +14 -0
- package/dist/src/engine/version.d.ts.map +1 -0
- package/dist/src/engine/version.js +43 -0
- package/dist/src/engine/version.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/server.d.ts +25 -5
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +45 -7
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/reporter/agent-diagnostic.d.ts +6 -0
- package/dist/src/reporter/agent-diagnostic.d.ts.map +1 -1
- package/dist/src/reporter/agent-diagnostic.js +53 -3
- package/dist/src/reporter/agent-diagnostic.js.map +1 -1
- package/dist/src/reporter/html-report.d.ts.map +1 -1
- package/dist/src/reporter/html-report.js +79 -4
- package/dist/src/reporter/html-report.js.map +1 -1
- package/dist/src/reporter/terminal-summary.d.ts.map +1 -1
- package/dist/src/reporter/terminal-summary.js +6 -0
- package/dist/src/reporter/terminal-summary.js.map +1 -1
- package/dist/src/traps/a11y-trap.d.ts +23 -0
- package/dist/src/traps/a11y-trap.d.ts.map +1 -1
- package/dist/src/traps/a11y-trap.js +185 -0
- package/dist/src/traps/a11y-trap.js.map +1 -1
- package/dist/src/traps/console-trap.d.ts +3 -1
- package/dist/src/traps/console-trap.d.ts.map +1 -1
- package/dist/src/traps/console-trap.js +41 -0
- package/dist/src/traps/console-trap.js.map +1 -1
- package/dist/src/traps/layout-trap.d.ts +31 -0
- package/dist/src/traps/layout-trap.d.ts.map +1 -1
- package/dist/src/traps/layout-trap.js +173 -0
- package/dist/src/traps/layout-trap.js.map +1 -1
- package/dist/src/traps/links-trap.d.ts +34 -0
- package/dist/src/traps/links-trap.d.ts.map +1 -0
- package/dist/src/traps/links-trap.js +136 -0
- package/dist/src/traps/links-trap.js.map +1 -0
- package/dist/src/traps/network-trap.d.ts +4 -1
- package/dist/src/traps/network-trap.d.ts.map +1 -1
- package/dist/src/traps/network-trap.js +29 -2
- package/dist/src/traps/network-trap.js.map +1 -1
- package/dist/src/traps/perf-trap.d.ts +18 -0
- package/dist/src/traps/perf-trap.d.ts.map +1 -1
- package/dist/src/traps/perf-trap.js +148 -8
- package/dist/src/traps/perf-trap.js.map +1 -1
- package/dist/src/traps/seo-trap.d.ts +30 -0
- package/dist/src/traps/seo-trap.d.ts.map +1 -0
- package/dist/src/traps/seo-trap.js +98 -0
- package/dist/src/traps/seo-trap.js.map +1 -0
- package/dist/src/traps/theme-trap.d.ts +32 -0
- package/dist/src/traps/theme-trap.d.ts.map +1 -0
- package/dist/src/traps/theme-trap.js +109 -0
- package/dist/src/traps/theme-trap.js.map +1 -0
- package/dist/src/types.d.ts +55 -3
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +30 -2
- package/dist/src/types.js.map +1 -1
- package/package.json +99 -91
package/dist/bin/ghost-qa.js
CHANGED
|
@@ -1,406 +1,499 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { runGhostQAAudit, GHOST_QA_VERSION } from '../src/engine/audit-runner.js';
|
|
7
|
-
import { ManagedDevServer } from '../src/engine/server-lifecycle.js';
|
|
8
|
-
import { mcpServerConfig, runDoctorCheck, startMcpServer } from '../src/mcp/server.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
ghost-qa
|
|
54
|
-
ghost-qa
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
--
|
|
63
|
-
--
|
|
64
|
-
--
|
|
65
|
-
--
|
|
66
|
-
|
|
67
|
-
--
|
|
68
|
-
--
|
|
69
|
-
|
|
70
|
-
--
|
|
71
|
-
--
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (value === '
|
|
135
|
-
options.
|
|
136
|
-
else
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
throw new Error(
|
|
150
|
-
break;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
let
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { runGhostQAAudit, GHOST_QA_VERSION } from '../src/engine/audit-runner.js';
|
|
7
|
+
import { ManagedDevServer } from '../src/engine/server-lifecycle.js';
|
|
8
|
+
import { mcpServerConfig, runDoctorCheck, startMcpServer } from '../src/mcp/server.js';
|
|
9
|
+
import { compareReports, formatCompareReport } from '../src/engine/compare.js';
|
|
10
|
+
import { formatAgentDiagnostic, formatDiagnostic, formatPatchPrompt } from '../src/reporter/agent-diagnostic.js';
|
|
11
|
+
import { formatHtmlReport } from '../src/reporter/html-report.js';
|
|
12
|
+
import { formatTerminalSummary } from '../src/reporter/terminal-summary.js';
|
|
13
|
+
import { DEFAULT_OPTIONS } from '../src/types.js';
|
|
14
|
+
export const VERSION = GHOST_QA_VERSION;
|
|
15
|
+
const PROTOCOL_START = '<!-- ghost-qa:start -->';
|
|
16
|
+
const PROTOCOL_END = '<!-- ghost-qa:end -->';
|
|
17
|
+
export const AGENT_PROTOCOL = `${PROTOCOL_START}
|
|
18
|
+
## š» ghost-qa ā Browser Verification Protocol (MANDATORY)
|
|
19
|
+
|
|
20
|
+
Before reporting any task that touches frontend code as complete, you MUST verify it in a real browser ā the closed-loop Self-Healing Browser Verification Protocol:
|
|
21
|
+
|
|
22
|
+
1. **Readiness:** run \`ghost-qa doctor\` (or the \`ghost_qa_doctor\` MCP tool). Do not proceed on red.
|
|
23
|
+
2. **Baseline audit:** ensure the app runs locally, then \`npx -y ghost-qa test <local-url> --out-dir ghost-qa-out\` (add \`--server-cmd "npm run dev"\` to let ghost-qa manage the server) ā or call \`ghost_qa_audit_url\`. This runs the 8-Trap Engine on Desktop 1440Ć900, Tablet 768Ć1024 and Mobile 375Ć812, light + dark. Save the baseline as \`before.json\` (it is \`ghost-qa-out/report.json\`).
|
|
24
|
+
3. **Root-cause mapping:** for every critical/major finding, map the exact DOM selector, bounding box, stack trace or dark-mode contrast failure back to the component file that renders it.
|
|
25
|
+
4. **Surgical remediation:** fix root causes in source (hydration, overflow, tap targets, ARIA/labels, dark-mode tokensā¦). Never silence the checker or delete failing UI.
|
|
26
|
+
5. **Delta verification:** re-run the audit to \`after.json\`, then \`npx -y ghost-qa compare before.json after.json\` (or \`ghost_qa_compare\`). Repeat until QA Health Score is **95ā100 (A+)**, zero critical/major remain, and the delta shows **zero new regressions**. Only then report done, citing score, routes, viewports and the delta.
|
|
27
|
+
|
|
28
|
+
Never declare a UI task finished based only on code review, type-checking, or unit tests. The browser is the source of truth.
|
|
29
|
+
${PROTOCOL_END}`;
|
|
30
|
+
const FALLBACK_SKILL = `# ghost-qa ā Verify in Browser Before Done
|
|
31
|
+
|
|
32
|
+
You are a coding agent. Never report a frontend task complete without running it in a real browser ā the browser is the source of truth, not your mental model of the DOM.
|
|
33
|
+
|
|
34
|
+
## The Self-Healing Loop
|
|
35
|
+
|
|
36
|
+
1. Run the audit against the final code:
|
|
37
|
+
- Server already running: \`npx -y ghost-qa test <local-url>\`
|
|
38
|
+
- Otherwise: \`npx -y ghost-qa test <local-url> --server-cmd "npm run dev"\` (ghost-qa spawns, awaits and tears down the server)
|
|
39
|
+
- Or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it (identical engine).
|
|
40
|
+
It crawls routes on Desktop 1440Ć900 + Tablet 768Ć1024 + Mobile 375Ć812 (light + dark) and runs the 8-Trap Engine: console/framework runtime, network/asset integrity, layout/geometry/collision, WCAG a11y + keyboard focus, Core Web Vitals, link integrity, SEO/metadata, theme parity ā plus safe interaction testing (never destructive controls). Save the baseline JSON as before.json.
|
|
41
|
+
2. Read the Self-Healing Bug Report (GHOST-QA-REPORT.md) and PATCH-PROMPT.md. Map each selector/bounding box/stack to the component that renders it; fix every critical/major issue by \`#id\`, at the root cause in source ā never silence the checker, delete failing UI, or add suppressHydrationWarning.
|
|
42
|
+
3. Re-audit to after.json, then run \`npx -y ghost-qa compare before.json after.json\` (or \`ghost_qa_compare\`). Repeat until QA Health Score is 95ā100 (A+), zero critical/major remain and the delta shows zero new regressions. If stuck on the same \`#id\` after 5 loops, report that honestly instead of declaring success.
|
|
43
|
+
4. Only then report completion, naming the URL verified, routes crawled, interactions tested, score/grade and issues fixed.
|
|
44
|
+
|
|
45
|
+
Useful flags: \`--max-depth\`, \`--max-routes\`, \`--viewport mobile\`, \`--color-scheme dark\`, \`--screenshots\`, \`--out-dir ghost-qa-out\`, \`--json\`, \`--report <file>\`, \`--html <file>\`, \`--fail-on minor\`.
|
|
46
|
+
If Chromium is missing: \`npx playwright install chromium\`, then \`npx -y ghost-qa doctor\`.
|
|
47
|
+
Full skill: https://github.com/avivgerz-hue/ghost-qa/blob/main/SKILL.md
|
|
48
|
+
`;
|
|
49
|
+
const HELP = `
|
|
50
|
+
š» ghost-qa v${VERSION} ā autonomous headless browser QA for coding agents
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
ghost-qa test <url> [options] Crawl a running app and trap browser failures
|
|
54
|
+
ghost-qa compare <before.json> <after.json> [--json]
|
|
55
|
+
Regression & Verification Delta Report
|
|
56
|
+
ghost-qa init [--force] Install the agent skill, protocol + MCP config
|
|
57
|
+
ghost-qa mcp Run the native MCP server (JSON-RPC over stdio)
|
|
58
|
+
ghost-qa doctor Check Node, Playwright and Chromium setup
|
|
59
|
+
ghost-qa --help | --version
|
|
60
|
+
|
|
61
|
+
test options:
|
|
62
|
+
--max-depth <n> Link-following depth from the start URL (default ${DEFAULT_OPTIONS.maxDepth})
|
|
63
|
+
--max-routes <n> Maximum routes to visit (default ${DEFAULT_OPTIONS.maxRoutes})
|
|
64
|
+
--timeout <ms> Per-navigation timeout (default ${DEFAULT_OPTIONS.timeoutMs})
|
|
65
|
+
--viewport <name> all | desktop | tablet | mobile | both (default all)
|
|
66
|
+
--color-scheme <name> both | light | dark (default both)
|
|
67
|
+
--screenshots Capture route screenshots for the HTML dashboard
|
|
68
|
+
--out-dir <dir> Write report.json + GHOST-QA-REPORT.md +
|
|
69
|
+
PATCH-PROMPT.md + report.html into this directory
|
|
70
|
+
--no-interactions Only load pages; do not click/fill anything
|
|
71
|
+
--headed Show the browser window (debugging)
|
|
72
|
+
--fail-on <severity> Fail on critical | major | minor (default ${DEFAULT_OPTIONS.failOn})
|
|
73
|
+
--server-cmd <cmd> Spawn this dev-server command first, wait for the URL,
|
|
74
|
+
run the audit, then terminate the server tree
|
|
75
|
+
--server-timeout <ms> How long to wait for --server-cmd to respond (default 30000)
|
|
76
|
+
--json Print the machine-readable JSON report to stdout
|
|
77
|
+
--report <file> Write the diagnostic to a file (.md or .json by extension)
|
|
78
|
+
--html <file> Write the visual HTML evidence dashboard to a file
|
|
79
|
+
--quiet Suppress the terminal summary (report/exit code only)
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
npx -y ghost-qa test http://localhost:3000
|
|
83
|
+
npx -y ghost-qa test http://localhost:3000 --server-cmd "npm run dev"
|
|
84
|
+
npx -y ghost-qa test http://localhost:5173 --max-depth 3 --html ghost-qa-report.html
|
|
85
|
+
npx -y ghost-qa test http://localhost:3000 --out-dir ghost-qa-out --screenshots
|
|
86
|
+
npx -y ghost-qa test http://localhost:3000 --json > before.json
|
|
87
|
+
npx -y ghost-qa compare before.json after.json
|
|
88
|
+
npx -y ghost-qa init
|
|
89
|
+
npx -y ghost-qa mcp
|
|
90
|
+
|
|
91
|
+
No npm? Every command also works straight from GitHub, e.g.
|
|
92
|
+
npx -y github:avivgerz-hue/ghost-qa test http://localhost:3000
|
|
93
|
+
`;
|
|
94
|
+
function parsePositiveInt(value, flag) {
|
|
95
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
96
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
97
|
+
throw new Error(`${flag} expects a positive integer, got "${value ?? ''}"`);
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
function parseNonNegativeInt(value, flag) {
|
|
102
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
103
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
104
|
+
throw new Error(`${flag} expects a non-negative integer, got "${value ?? ''}"`);
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
export function parseTestArgs(args) {
|
|
109
|
+
const options = { ...DEFAULT_OPTIONS, viewports: [...DEFAULT_OPTIONS.viewports] };
|
|
110
|
+
let target = '';
|
|
111
|
+
let json = false;
|
|
112
|
+
let quiet = false;
|
|
113
|
+
let reportFile;
|
|
114
|
+
let htmlFile;
|
|
115
|
+
let outDir;
|
|
116
|
+
let serverCmd;
|
|
117
|
+
let serverTimeoutMs = 30_000;
|
|
118
|
+
for (let i = 0; i < args.length; i++) {
|
|
119
|
+
const arg = args[i];
|
|
120
|
+
switch (arg) {
|
|
121
|
+
case '--max-depth':
|
|
122
|
+
options.maxDepth = parseNonNegativeInt(args[++i], arg);
|
|
123
|
+
break;
|
|
124
|
+
case '--max-routes':
|
|
125
|
+
options.maxRoutes = parsePositiveInt(args[++i], arg);
|
|
126
|
+
break;
|
|
127
|
+
case '--timeout':
|
|
128
|
+
options.timeoutMs = parsePositiveInt(args[++i], arg);
|
|
129
|
+
break;
|
|
130
|
+
case '--viewport': {
|
|
131
|
+
const value = args[++i];
|
|
132
|
+
if (value === 'desktop' || value === 'tablet' || value === 'mobile')
|
|
133
|
+
options.viewports = [value];
|
|
134
|
+
else if (value === 'both')
|
|
135
|
+
options.viewports = ['desktop', 'mobile'];
|
|
136
|
+
else if (value === 'all')
|
|
137
|
+
options.viewports = ['desktop', 'tablet', 'mobile'];
|
|
138
|
+
else
|
|
139
|
+
throw new Error(`--viewport expects all|both|desktop|tablet|mobile, got "${value ?? ''}"`);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case '--color-scheme': {
|
|
143
|
+
const value = args[++i];
|
|
144
|
+
if (value === 'light' || value === 'dark')
|
|
145
|
+
options.colorSchemes = [value];
|
|
146
|
+
else if (value === 'both')
|
|
147
|
+
options.colorSchemes = ['light', 'dark'];
|
|
148
|
+
else
|
|
149
|
+
throw new Error(`--color-scheme expects both|light|dark, got "${value ?? ''}"`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case '--screenshots':
|
|
153
|
+
options.screenshots = true;
|
|
154
|
+
break;
|
|
155
|
+
case '--out-dir':
|
|
156
|
+
outDir = args[++i];
|
|
157
|
+
if (!outDir)
|
|
158
|
+
throw new Error('--out-dir expects a directory path');
|
|
159
|
+
break;
|
|
160
|
+
case '--no-interactions':
|
|
161
|
+
options.interactions = false;
|
|
162
|
+
break;
|
|
163
|
+
case '--headed':
|
|
164
|
+
options.headless = false;
|
|
165
|
+
break;
|
|
166
|
+
case '--fail-on': {
|
|
167
|
+
const value = args[++i];
|
|
168
|
+
if (value === 'critical' || value === 'major' || value === 'minor')
|
|
169
|
+
options.failOn = value;
|
|
170
|
+
else
|
|
171
|
+
throw new Error(`--fail-on expects critical|major|minor, got "${value ?? ''}"`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case '--json':
|
|
175
|
+
json = true;
|
|
176
|
+
break;
|
|
177
|
+
case '--quiet':
|
|
178
|
+
quiet = true;
|
|
179
|
+
break;
|
|
180
|
+
case '--report':
|
|
181
|
+
reportFile = args[++i];
|
|
182
|
+
if (!reportFile)
|
|
183
|
+
throw new Error('--report expects a file path');
|
|
184
|
+
break;
|
|
185
|
+
case '--html':
|
|
186
|
+
htmlFile = args[++i];
|
|
187
|
+
if (!htmlFile)
|
|
188
|
+
throw new Error('--html expects a file path');
|
|
189
|
+
break;
|
|
190
|
+
case '--server-cmd':
|
|
191
|
+
serverCmd = args[++i];
|
|
192
|
+
if (!serverCmd)
|
|
193
|
+
throw new Error('--server-cmd expects a command, e.g. --server-cmd "npm run dev"');
|
|
194
|
+
break;
|
|
195
|
+
case '--server-timeout':
|
|
196
|
+
serverTimeoutMs = parsePositiveInt(args[++i], arg);
|
|
197
|
+
break;
|
|
198
|
+
default:
|
|
199
|
+
if (arg.startsWith('--'))
|
|
200
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
201
|
+
if (!target)
|
|
202
|
+
target = arg;
|
|
203
|
+
else
|
|
204
|
+
throw new Error(`Unexpected extra argument: ${arg}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!target)
|
|
208
|
+
throw new Error('Missing target URL. Usage: ghost-qa test <url>');
|
|
209
|
+
return {
|
|
210
|
+
target,
|
|
211
|
+
options,
|
|
212
|
+
json,
|
|
213
|
+
quiet,
|
|
214
|
+
serverTimeoutMs,
|
|
215
|
+
...(reportFile ? { reportFile } : {}),
|
|
216
|
+
...(htmlFile ? { htmlFile } : {}),
|
|
217
|
+
...(outDir ? { outDir } : {}),
|
|
218
|
+
...(serverCmd ? { serverCmd } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function parseCompareArgs(args) {
|
|
222
|
+
const files = [];
|
|
223
|
+
let json = false;
|
|
224
|
+
let outFile;
|
|
225
|
+
for (let i = 0; i < args.length; i++) {
|
|
226
|
+
const arg = args[i];
|
|
227
|
+
if (arg === '--json')
|
|
228
|
+
json = true;
|
|
229
|
+
else if (arg === '--out') {
|
|
230
|
+
outFile = args[++i];
|
|
231
|
+
if (!outFile)
|
|
232
|
+
throw new Error('--out expects a file path');
|
|
233
|
+
}
|
|
234
|
+
else if (arg.startsWith('--'))
|
|
235
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
236
|
+
else
|
|
237
|
+
files.push(arg);
|
|
238
|
+
}
|
|
239
|
+
if (files.length !== 2) {
|
|
240
|
+
throw new Error('Usage: ghost-qa compare <before.json> <after.json> [--json] [--out <file>]');
|
|
241
|
+
}
|
|
242
|
+
return { beforeFile: files[0], afterFile: files[1], json, ...(outFile ? { outFile } : {}) };
|
|
243
|
+
}
|
|
244
|
+
async function runCompare(args) {
|
|
245
|
+
const parsed = parseCompareArgs(args);
|
|
246
|
+
const load = async (file) => {
|
|
247
|
+
const parsedJson = JSON.parse(await readFile(resolve(file), 'utf8'));
|
|
248
|
+
if (typeof parsedJson !== 'object' ||
|
|
249
|
+
parsedJson === null ||
|
|
250
|
+
!Array.isArray(parsedJson.issues)) {
|
|
251
|
+
throw new Error(`${file} is not a ghost-qa report JSON (missing issues array)`);
|
|
252
|
+
}
|
|
253
|
+
return parsedJson;
|
|
254
|
+
};
|
|
255
|
+
const delta = compareReports(await load(parsed.beforeFile), await load(parsed.afterFile));
|
|
256
|
+
const rendered = parsed.json ? `${JSON.stringify(delta, null, 2)}\n` : formatCompareReport(delta);
|
|
257
|
+
if (parsed.outFile) {
|
|
258
|
+
await writeFile(resolve(parsed.outFile), rendered, 'utf8');
|
|
259
|
+
process.stdout.write(`Delta report written to ${parsed.outFile}\n`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
process.stdout.write(rendered);
|
|
263
|
+
}
|
|
264
|
+
return delta.newRegressions.length > 0 ? 1 : 0;
|
|
265
|
+
}
|
|
266
|
+
async function executeTest(parsed) {
|
|
267
|
+
const audit = () => runGhostQAAudit(parsed.target, parsed.options);
|
|
268
|
+
if (!parsed.serverCmd)
|
|
269
|
+
return audit();
|
|
270
|
+
const server = new ManagedDevServer(parsed.serverCmd);
|
|
271
|
+
process.stderr.write(`\nš» ghost-qa: starting dev server: ${parsed.serverCmd}\n`);
|
|
272
|
+
server.start();
|
|
273
|
+
try {
|
|
274
|
+
await server.waitUntilReady(parsed.target, parsed.serverTimeoutMs);
|
|
275
|
+
process.stderr.write(`š» ghost-qa: server is responding at ${parsed.target}\n`);
|
|
276
|
+
return await audit();
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await server.stop();
|
|
280
|
+
process.stderr.write('š» ghost-qa: dev server terminated.\n');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function loadSkillTemplate() {
|
|
284
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
285
|
+
const candidates = [
|
|
286
|
+
resolve(moduleDir, '../../SKILL.md'), // dist/bin -> package root
|
|
287
|
+
resolve(moduleDir, '../SKILL.md'),
|
|
288
|
+
resolve(process.cwd(), 'SKILL.md'),
|
|
289
|
+
];
|
|
290
|
+
for (const candidate of candidates) {
|
|
291
|
+
try {
|
|
292
|
+
const content = await readFile(candidate, 'utf8');
|
|
293
|
+
if (content.includes('ghost-qa'))
|
|
294
|
+
return content;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* try next candidate */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return FALLBACK_SKILL;
|
|
301
|
+
}
|
|
302
|
+
async function upsertProtocol(filePath) {
|
|
303
|
+
let existing = '';
|
|
304
|
+
try {
|
|
305
|
+
existing = await readFile(filePath, 'utf8');
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
await writeFile(filePath, `# Agent Instructions\n\n${AGENT_PROTOCOL}\n`, 'utf8');
|
|
309
|
+
return 'created';
|
|
310
|
+
}
|
|
311
|
+
const start = existing.indexOf(PROTOCOL_START);
|
|
312
|
+
const end = existing.indexOf(PROTOCOL_END);
|
|
313
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
314
|
+
const next = `${existing.slice(0, start)}${AGENT_PROTOCOL}${existing.slice(end + PROTOCOL_END.length)}`;
|
|
315
|
+
if (next === existing)
|
|
316
|
+
return 'unchanged';
|
|
317
|
+
await writeFile(filePath, next, 'utf8');
|
|
318
|
+
return 'updated';
|
|
319
|
+
}
|
|
320
|
+
await writeFile(filePath, `${existing.replace(/\s*$/, '')}\n\n${AGENT_PROTOCOL}\n`, 'utf8');
|
|
321
|
+
return 'updated';
|
|
322
|
+
}
|
|
323
|
+
export async function installSkill(targetDir, force = false) {
|
|
324
|
+
const results = [];
|
|
325
|
+
const skillPath = join(targetDir, '.claude', 'skills', 'ghost-qa', 'SKILL.md');
|
|
326
|
+
await mkdir(dirname(skillPath), { recursive: true });
|
|
327
|
+
const skillContent = await loadSkillTemplate();
|
|
328
|
+
const skillExisted = existsSync(skillPath);
|
|
329
|
+
if (!skillExisted || force) {
|
|
330
|
+
await writeFile(skillPath, skillContent.endsWith('\n') ? skillContent : `${skillContent}\n`, 'utf8');
|
|
331
|
+
results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: skillExisted ? 'updated (--force)' : 'installed' });
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: 'already exists (use --force to overwrite)' });
|
|
335
|
+
}
|
|
336
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
|
|
337
|
+
const action = await upsertProtocol(join(targetDir, name));
|
|
338
|
+
results.push({ file: name, action: `protocol ${action}` });
|
|
339
|
+
}
|
|
340
|
+
const mcpAction = await installMcpConfig(join(targetDir, '.mcp.json'), force);
|
|
341
|
+
results.push({ file: '.mcp.json', action: mcpAction });
|
|
342
|
+
return results;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Writes (or merges into) `.mcp.json`, preserving any MCP servers the
|
|
346
|
+
* project already configured. The ghost-qa entry is always refreshed so
|
|
347
|
+
* hosts invoke the published CLI: `npx -y ghost-qa mcp`.
|
|
348
|
+
*/
|
|
349
|
+
export async function installMcpConfig(mcpJsonPath, force = false) {
|
|
350
|
+
const desired = mcpServerConfig();
|
|
351
|
+
let existing = {};
|
|
352
|
+
let action = 'installed';
|
|
353
|
+
try {
|
|
354
|
+
const raw = await readFile(mcpJsonPath, 'utf8');
|
|
355
|
+
const parsed = JSON.parse(raw);
|
|
356
|
+
if (parsed && typeof parsed === 'object') {
|
|
357
|
+
existing = parsed;
|
|
358
|
+
action = 'merged';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
action = 'installed';
|
|
363
|
+
}
|
|
364
|
+
const servers = (existing.mcpServers ?? {});
|
|
365
|
+
const previous = servers['ghost-qa'];
|
|
366
|
+
if (previous && !force && JSON.stringify(previous) === JSON.stringify(desired.mcpServers['ghost-qa'])) {
|
|
367
|
+
return 'already configured';
|
|
368
|
+
}
|
|
369
|
+
servers['ghost-qa'] = desired.mcpServers['ghost-qa'];
|
|
370
|
+
existing.mcpServers = servers;
|
|
371
|
+
await writeFile(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
|
|
372
|
+
return previous ? 'updated' : action;
|
|
373
|
+
}
|
|
374
|
+
async function runInit(args) {
|
|
375
|
+
const force = args.includes('--force');
|
|
376
|
+
const dirFlagIndex = args.indexOf('--dir');
|
|
377
|
+
const targetDir = dirFlagIndex !== -1 ? resolve(args[dirFlagIndex + 1] ?? '.') : process.cwd();
|
|
378
|
+
const results = await installSkill(targetDir, force);
|
|
379
|
+
process.stdout.write(`\nš» ghost-qa agent skill installed in ${targetDir}\n`);
|
|
380
|
+
for (const result of results) {
|
|
381
|
+
process.stdout.write(` ⢠${result.file} ā ${result.action}\n`);
|
|
382
|
+
}
|
|
383
|
+
process.stdout.write('\nYour coding agent will now run `npx -y ghost-qa test <local-url>` and fix browser failures before reporting a task complete.\n' +
|
|
384
|
+
'MCP hosts (Claude Code, Claude Desktop, Cursor, Codex) can also call the ghost_qa_audit_url / ghost_qa_doctor / ghost_qa_compare tools directly via the .mcp.json entry above.\n');
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
async function runDoctor() {
|
|
388
|
+
process.stdout.write(`\nš» ghost-qa doctor v${VERSION}\n`);
|
|
389
|
+
const result = await runDoctorCheck();
|
|
390
|
+
process.stdout.write(` ${result.nodeOk ? 'ā' : 'ā'} Node.js ${result.node} ${result.nodeOk ? '' : '(requires >= 20)'}\n`);
|
|
391
|
+
if (result.chromiumFound) {
|
|
392
|
+
process.stdout.write(` ā Chromium binary: ${result.chromiumPath}\n`);
|
|
393
|
+
const smokeOk = result.smokeTest.startsWith('Chromium launched');
|
|
394
|
+
process.stdout.write(` ${smokeOk ? 'ā' : 'ā'} Browser smoke test: ${result.smokeTest}\n`);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
process.stdout.write(' ā Chromium binary not found. Run: npx playwright install chromium\n');
|
|
398
|
+
}
|
|
399
|
+
process.stdout.write(result.ready
|
|
400
|
+
? '\nā ghost-qa is ready: npx -y ghost-qa test http://localhost:3000\n'
|
|
401
|
+
: '\nā Setup incomplete ā fix the items above.\n');
|
|
402
|
+
return result.ready ? 0 : 1;
|
|
403
|
+
}
|
|
404
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
405
|
+
const [command, ...rest] = argv;
|
|
406
|
+
try {
|
|
407
|
+
switch (command) {
|
|
408
|
+
case 'test': {
|
|
409
|
+
const parsed = parseTestArgs(rest);
|
|
410
|
+
const report = await executeTest(parsed);
|
|
411
|
+
if (parsed.reportFile) {
|
|
412
|
+
const isJson = parsed.reportFile.endsWith('.json');
|
|
413
|
+
await writeFile(resolve(parsed.reportFile), formatDiagnostic(report, isJson ? 'json' : 'markdown'), 'utf8');
|
|
414
|
+
}
|
|
415
|
+
if (parsed.htmlFile) {
|
|
416
|
+
await writeFile(resolve(parsed.htmlFile), formatHtmlReport(report), 'utf8');
|
|
417
|
+
}
|
|
418
|
+
if (parsed.outDir) {
|
|
419
|
+
const dir = resolve(parsed.outDir);
|
|
420
|
+
await mkdir(dir, { recursive: true });
|
|
421
|
+
await writeFile(join(dir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
422
|
+
await writeFile(join(dir, 'GHOST-QA-REPORT.md'), `${formatAgentDiagnostic(report)}\n`, 'utf8');
|
|
423
|
+
await writeFile(join(dir, 'PATCH-PROMPT.md'), formatPatchPrompt(report), 'utf8');
|
|
424
|
+
await writeFile(join(dir, 'report.html'), formatHtmlReport(report), 'utf8');
|
|
425
|
+
if (!parsed.quiet) {
|
|
426
|
+
process.stdout.write(`Artifacts written to ${parsed.outDir}/ (report.json, GHOST-QA-REPORT.md, PATCH-PROMPT.md, report.html)\n`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (parsed.json) {
|
|
430
|
+
process.stdout.write(formatDiagnostic(report, 'json'));
|
|
431
|
+
}
|
|
432
|
+
else if (!parsed.quiet) {
|
|
433
|
+
process.stdout.write(`${formatTerminalSummary(report)}\n`);
|
|
434
|
+
if (!report.passed) {
|
|
435
|
+
process.stdout.write(`\n${formatAgentDiagnostic(report)}\n`);
|
|
436
|
+
}
|
|
437
|
+
if (parsed.reportFile)
|
|
438
|
+
process.stdout.write(`Report written to ${parsed.reportFile}\n`);
|
|
439
|
+
if (parsed.htmlFile)
|
|
440
|
+
process.stdout.write(`HTML evidence report written to ${parsed.htmlFile}\n`);
|
|
441
|
+
}
|
|
442
|
+
return report.passed ? 0 : 1;
|
|
443
|
+
}
|
|
444
|
+
case 'compare':
|
|
445
|
+
return await runCompare(rest);
|
|
446
|
+
case 'mcp':
|
|
447
|
+
await startMcpServer();
|
|
448
|
+
return 0;
|
|
449
|
+
case 'init':
|
|
450
|
+
return await runInit(rest);
|
|
451
|
+
case 'doctor':
|
|
452
|
+
return await runDoctor();
|
|
453
|
+
case '--version':
|
|
454
|
+
case '-v':
|
|
455
|
+
case 'version':
|
|
456
|
+
process.stdout.write(`ghost-qa ${VERSION}\n`);
|
|
457
|
+
return 0;
|
|
458
|
+
case '--help':
|
|
459
|
+
case '-h':
|
|
460
|
+
case 'help':
|
|
461
|
+
case undefined:
|
|
462
|
+
process.stdout.write(`${HELP}\n`);
|
|
463
|
+
return 0;
|
|
464
|
+
default:
|
|
465
|
+
process.stderr.write(`Unknown command: ${command}\n${HELP}\n`);
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
process.stderr.write(`\nš» ghost-qa error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
471
|
+
return 2;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* True when this module is the process entry point. The entry path MUST be
|
|
476
|
+
* realpath-resolved first: package managers invoke bins through a
|
|
477
|
+
* `node_modules/.bin/ghost-qa` symlink, while `import.meta.url` always
|
|
478
|
+
* points at the real file ā comparing unresolved paths would make the CLI
|
|
479
|
+
* silently no-op (exit 0, no output) under `npx`.
|
|
480
|
+
*/
|
|
481
|
+
export function isMainEntry(argvEntry, moduleUrl = import.meta.url) {
|
|
482
|
+
if (!argvEntry)
|
|
483
|
+
return false;
|
|
484
|
+
try {
|
|
485
|
+
return moduleUrl === pathToFileURL(realpathSync(resolve(argvEntry))).href;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (isMainEntry(process.argv[1])) {
|
|
492
|
+
main().then((code) => {
|
|
493
|
+
process.exitCode = code;
|
|
494
|
+
}, (error) => {
|
|
495
|
+
process.stderr.write(`ghost-qa crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
496
|
+
process.exitCode = 2;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
406
499
|
//# sourceMappingURL=ghost-qa.js.map
|