ghost-qa 1.3.1 → 1.3.2

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.
@@ -1,406 +1,409 @@
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 { formatAgentDiagnostic, formatDiagnostic } from '../src/reporter/agent-diagnostic.js';
10
- import { formatHtmlReport } from '../src/reporter/html-report.js';
11
- import { formatTerminalSummary } from '../src/reporter/terminal-summary.js';
12
- import { DEFAULT_OPTIONS } from '../src/types.js';
13
- export const VERSION = GHOST_QA_VERSION;
14
- const PROTOCOL_START = '<!-- ghost-qa:start -->';
15
- const PROTOCOL_END = '<!-- ghost-qa:end -->';
16
- export const AGENT_PROTOCOL = `${PROTOCOL_START}
17
- ## šŸ‘» ghost-qa — Browser Verification Protocol (MANDATORY)
18
-
19
- Before reporting any task that touches frontend code as complete, you MUST verify it in a real browser:
20
-
21
- 1. Ensure the app is running locally (e.g. \`npm run dev\`).
22
- 2. Run: \`npx github:avivgerz-hue/ghost-qa test <local-url>\` (e.g. \`npx github:avivgerz-hue/ghost-qa test http://localhost:3000\`) — or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it. Add \`--server-cmd "npm run dev"\` to let ghost-qa manage the dev server itself.
23
- 3. If ghost-qa reports ANY critical or major issue (hydration errors, console errors, 4xx/5xx responses, mobile overflow, accessibility or performance failures, broken interactions), fix the root cause in source and re-run.
24
- 4. Repeat until ghost-qa prints **PASS**. Only then may you tell the user the task is done.
25
-
26
- Never declare a UI task finished based only on code review, type-checking, or unit tests. The browser is the source of truth.
27
- ${PROTOCOL_END}`;
28
- const FALLBACK_SKILL = `# ghost-qa — Verify in Browser Before Done
29
-
30
- 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.
31
-
32
- ## The Self-Healing Loop
33
-
34
- 1. Run the audit against the final code:
35
- - Server already running: \`npx github:avivgerz-hue/ghost-qa test <local-url>\`
36
- - Otherwise: \`npx github:avivgerz-hue/ghost-qa test <local-url> --server-cmd "npm run dev"\` (ghost-qa spawns, awaits and tears down the server)
37
- - Or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it (identical engine).
38
- It crawls routes on Desktop 1440x900 + Mobile 390x844 and runs the 5-Trap Engine: console/hydration, network, layout, accessibility, performance — plus safe interaction testing (never destructive controls).
39
- 2. Read the Self-Healing Bug Report. Fix every critical/major issue by \`#id\`, at the root cause in source — never silence the checker, delete failing UI, or add suppressHydrationWarning.
40
- 3. Re-run the identical command until it prints \`GHOST-QA PASS\`. If stuck on the same \`#id\` after 5 loops, report that honestly instead of declaring success.
41
- 4. Only then report completion, naming the URL verified, routes crawled, interactions tested, and issues fixed.
42
-
43
- Useful flags: \`--max-depth\`, \`--max-routes\`, \`--viewport mobile\`, \`--json\`, \`--report <file>\`, \`--html <file>\`, \`--fail-on minor\`.
44
- If Chromium is missing: \`npx playwright install chromium\`, then \`npx github:avivgerz-hue/ghost-qa doctor\`.
45
- Full skill: https://github.com/avivgerz-hue/ghost-qa/blob/main/SKILL.md
46
- `;
47
- const HELP = `
48
- šŸ‘» ghost-qa v${VERSION} — autonomous headless browser QA for coding agents
49
-
50
- Usage:
51
- ghost-qa test <url> [options] Crawl a running app and trap browser failures
52
- ghost-qa init [--force] Install the agent skill, protocol + MCP config
53
- ghost-qa mcp Run the native MCP server (JSON-RPC over stdio)
54
- ghost-qa doctor Check Node, Playwright and Chromium setup
55
- ghost-qa --help | --version
56
-
57
- test options:
58
- --max-depth <n> Link-following depth from the start URL (default ${DEFAULT_OPTIONS.maxDepth})
59
- --max-routes <n> Maximum routes to visit (default ${DEFAULT_OPTIONS.maxRoutes})
60
- --timeout <ms> Per-navigation timeout (default ${DEFAULT_OPTIONS.timeoutMs})
61
- --viewport <name> both | desktop | mobile (default both)
62
- --no-interactions Only load pages; do not click/fill anything
63
- --headed Show the browser window (debugging)
64
- --fail-on <severity> Fail on critical | major | minor (default ${DEFAULT_OPTIONS.failOn})
65
- --server-cmd <cmd> Spawn this dev-server command first, wait for the URL,
66
- run the audit, then terminate the server tree
67
- --server-timeout <ms> How long to wait for --server-cmd to respond (default 30000)
68
- --json Print the machine-readable JSON report to stdout
69
- --report <file> Write the diagnostic to a file (.md or .json by extension)
70
- --html <file> Write the visual HTML evidence dashboard to a file
71
- --quiet Suppress the terminal summary (report/exit code only)
72
-
73
- Examples:
74
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000
75
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000 --server-cmd "npm run dev"
76
- npx github:avivgerz-hue/ghost-qa test http://localhost:5173 --max-depth 3 --html ghost-qa-report.html
77
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000 --json > report.json
78
- npx github:avivgerz-hue/ghost-qa init
79
- npx github:avivgerz-hue/ghost-qa mcp
80
- `;
81
- function parsePositiveInt(value, flag) {
82
- const parsed = Number.parseInt(value ?? '', 10);
83
- if (!Number.isFinite(parsed) || parsed <= 0) {
84
- throw new Error(`${flag} expects a positive integer, got "${value ?? ''}"`);
85
- }
86
- return parsed;
87
- }
88
- function parseNonNegativeInt(value, flag) {
89
- const parsed = Number.parseInt(value ?? '', 10);
90
- if (!Number.isFinite(parsed) || parsed < 0) {
91
- throw new Error(`${flag} expects a non-negative integer, got "${value ?? ''}"`);
92
- }
93
- return parsed;
94
- }
95
- export function parseTestArgs(args) {
96
- const options = { ...DEFAULT_OPTIONS, viewports: [...DEFAULT_OPTIONS.viewports] };
97
- let target = '';
98
- let json = false;
99
- let quiet = false;
100
- let reportFile;
101
- let htmlFile;
102
- let serverCmd;
103
- let serverTimeoutMs = 30_000;
104
- for (let i = 0; i < args.length; i++) {
105
- const arg = args[i];
106
- switch (arg) {
107
- case '--max-depth':
108
- options.maxDepth = parseNonNegativeInt(args[++i], arg);
109
- break;
110
- case '--max-routes':
111
- options.maxRoutes = parsePositiveInt(args[++i], arg);
112
- break;
113
- case '--timeout':
114
- options.timeoutMs = parsePositiveInt(args[++i], arg);
115
- break;
116
- case '--viewport': {
117
- const value = args[++i];
118
- if (value === 'desktop' || value === 'mobile')
119
- options.viewports = [value];
120
- else if (value === 'both')
121
- options.viewports = ['desktop', 'mobile'];
122
- else
123
- throw new Error(`--viewport expects both|desktop|mobile, got "${value ?? ''}"`);
124
- break;
125
- }
126
- case '--no-interactions':
127
- options.interactions = false;
128
- break;
129
- case '--headed':
130
- options.headless = false;
131
- break;
132
- case '--fail-on': {
133
- const value = args[++i];
134
- if (value === 'critical' || value === 'major' || value === 'minor')
135
- options.failOn = value;
136
- else
137
- throw new Error(`--fail-on expects critical|major|minor, got "${value ?? ''}"`);
138
- break;
139
- }
140
- case '--json':
141
- json = true;
142
- break;
143
- case '--quiet':
144
- quiet = true;
145
- break;
146
- case '--report':
147
- reportFile = args[++i];
148
- if (!reportFile)
149
- throw new Error('--report expects a file path');
150
- break;
151
- case '--html':
152
- htmlFile = args[++i];
153
- if (!htmlFile)
154
- throw new Error('--html expects a file path');
155
- break;
156
- case '--server-cmd':
157
- serverCmd = args[++i];
158
- if (!serverCmd)
159
- throw new Error('--server-cmd expects a command, e.g. --server-cmd "npm run dev"');
160
- break;
161
- case '--server-timeout':
162
- serverTimeoutMs = parsePositiveInt(args[++i], arg);
163
- break;
164
- default:
165
- if (arg.startsWith('--'))
166
- throw new Error(`Unknown flag: ${arg}`);
167
- if (!target)
168
- target = arg;
169
- else
170
- throw new Error(`Unexpected extra argument: ${arg}`);
171
- }
172
- }
173
- if (!target)
174
- throw new Error('Missing target URL. Usage: ghost-qa test <url>');
175
- return {
176
- target,
177
- options,
178
- json,
179
- quiet,
180
- serverTimeoutMs,
181
- ...(reportFile ? { reportFile } : {}),
182
- ...(htmlFile ? { htmlFile } : {}),
183
- ...(serverCmd ? { serverCmd } : {}),
184
- };
185
- }
186
- async function executeTest(parsed) {
187
- const audit = () => runGhostQAAudit(parsed.target, parsed.options);
188
- if (!parsed.serverCmd)
189
- return audit();
190
- const server = new ManagedDevServer(parsed.serverCmd);
191
- process.stderr.write(`\nšŸ‘» ghost-qa: starting dev server: ${parsed.serverCmd}\n`);
192
- server.start();
193
- try {
194
- await server.waitUntilReady(parsed.target, parsed.serverTimeoutMs);
195
- process.stderr.write(`šŸ‘» ghost-qa: server is responding at ${parsed.target}\n`);
196
- return await audit();
197
- }
198
- finally {
199
- await server.stop();
200
- process.stderr.write('šŸ‘» ghost-qa: dev server terminated.\n');
201
- }
202
- }
203
- async function loadSkillTemplate() {
204
- const moduleDir = dirname(fileURLToPath(import.meta.url));
205
- const candidates = [
206
- resolve(moduleDir, '../../SKILL.md'), // dist/bin -> package root
207
- resolve(moduleDir, '../SKILL.md'),
208
- resolve(process.cwd(), 'SKILL.md'),
209
- ];
210
- for (const candidate of candidates) {
211
- try {
212
- const content = await readFile(candidate, 'utf8');
213
- if (content.includes('ghost-qa'))
214
- return content;
215
- }
216
- catch {
217
- /* try next candidate */
218
- }
219
- }
220
- return FALLBACK_SKILL;
221
- }
222
- async function upsertProtocol(filePath) {
223
- let existing = '';
224
- try {
225
- existing = await readFile(filePath, 'utf8');
226
- }
227
- catch {
228
- await writeFile(filePath, `# Agent Instructions\n\n${AGENT_PROTOCOL}\n`, 'utf8');
229
- return 'created';
230
- }
231
- const start = existing.indexOf(PROTOCOL_START);
232
- const end = existing.indexOf(PROTOCOL_END);
233
- if (start !== -1 && end !== -1 && end > start) {
234
- const next = `${existing.slice(0, start)}${AGENT_PROTOCOL}${existing.slice(end + PROTOCOL_END.length)}`;
235
- if (next === existing)
236
- return 'unchanged';
237
- await writeFile(filePath, next, 'utf8');
238
- return 'updated';
239
- }
240
- await writeFile(filePath, `${existing.replace(/\s*$/, '')}\n\n${AGENT_PROTOCOL}\n`, 'utf8');
241
- return 'updated';
242
- }
243
- export async function installSkill(targetDir, force = false) {
244
- const results = [];
245
- const skillPath = join(targetDir, '.claude', 'skills', 'ghost-qa', 'SKILL.md');
246
- await mkdir(dirname(skillPath), { recursive: true });
247
- const skillContent = await loadSkillTemplate();
248
- const skillExisted = existsSync(skillPath);
249
- if (!skillExisted || force) {
250
- await writeFile(skillPath, skillContent.endsWith('\n') ? skillContent : `${skillContent}\n`, 'utf8');
251
- results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: skillExisted ? 'updated (--force)' : 'installed' });
252
- }
253
- else {
254
- results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: 'already exists (use --force to overwrite)' });
255
- }
256
- for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
257
- const action = await upsertProtocol(join(targetDir, name));
258
- results.push({ file: name, action: `protocol ${action}` });
259
- }
260
- const mcpAction = await installMcpConfig(join(targetDir, '.mcp.json'), force);
261
- results.push({ file: '.mcp.json', action: mcpAction });
262
- return results;
263
- }
264
- /**
265
- * Writes (or merges into) `.mcp.json`, preserving any MCP servers the
266
- * project already configured. The ghost-qa entry is always refreshed so
267
- * hosts invoke the published CLI: `npx -y github:avivgerz-hue/ghost-qa mcp`.
268
- */
269
- export async function installMcpConfig(mcpJsonPath, force = false) {
270
- const desired = mcpServerConfig();
271
- let existing = {};
272
- let action = 'installed';
273
- try {
274
- const raw = await readFile(mcpJsonPath, 'utf8');
275
- const parsed = JSON.parse(raw);
276
- if (parsed && typeof parsed === 'object') {
277
- existing = parsed;
278
- action = 'merged';
279
- }
280
- }
281
- catch {
282
- action = 'installed';
283
- }
284
- const servers = (existing.mcpServers ?? {});
285
- const previous = servers['ghost-qa'];
286
- if (previous && !force && JSON.stringify(previous) === JSON.stringify(desired.mcpServers['ghost-qa'])) {
287
- return 'already configured';
288
- }
289
- servers['ghost-qa'] = desired.mcpServers['ghost-qa'];
290
- existing.mcpServers = servers;
291
- await writeFile(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
292
- return previous ? 'updated' : action;
293
- }
294
- async function runInit(args) {
295
- const force = args.includes('--force');
296
- const dirFlagIndex = args.indexOf('--dir');
297
- const targetDir = dirFlagIndex !== -1 ? resolve(args[dirFlagIndex + 1] ?? '.') : process.cwd();
298
- const results = await installSkill(targetDir, force);
299
- process.stdout.write(`\nšŸ‘» ghost-qa agent skill installed in ${targetDir}\n`);
300
- for (const result of results) {
301
- process.stdout.write(` • ${result.file} — ${result.action}\n`);
302
- }
303
- process.stdout.write('\nYour coding agent will now run `npx github:avivgerz-hue/ghost-qa test <local-url>` and fix browser failures before reporting a task complete.\n' +
304
- 'MCP hosts (Claude Code, Claude Desktop, Cursor, Codex) can also call the ghost_qa_audit_url / ghost_qa_doctor tools directly via the .mcp.json entry above.\n');
305
- return 0;
306
- }
307
- async function runDoctor() {
308
- process.stdout.write(`\nšŸ‘» ghost-qa doctor v${VERSION}\n`);
309
- const result = await runDoctorCheck();
310
- process.stdout.write(` ${result.nodeOk ? 'āœ”' : 'āœ–'} Node.js ${result.node} ${result.nodeOk ? '' : '(requires >= 20)'}\n`);
311
- if (result.chromiumFound) {
312
- process.stdout.write(` āœ” Chromium binary: ${result.chromiumPath}\n`);
313
- const smokeOk = result.smokeTest.startsWith('Chromium launched');
314
- process.stdout.write(` ${smokeOk ? 'āœ”' : 'āœ–'} Browser smoke test: ${result.smokeTest}\n`);
315
- }
316
- else {
317
- process.stdout.write(' āœ– Chromium binary not found. Run: npx playwright install chromium\n');
318
- }
319
- process.stdout.write(result.ready
320
- ? '\nāœ” ghost-qa is ready: npx github:avivgerz-hue/ghost-qa test http://localhost:3000\n'
321
- : '\nāœ– Setup incomplete — fix the items above.\n');
322
- return result.ready ? 0 : 1;
323
- }
324
- export async function main(argv = process.argv.slice(2)) {
325
- const [command, ...rest] = argv;
326
- try {
327
- switch (command) {
328
- case 'test': {
329
- const parsed = parseTestArgs(rest);
330
- const report = await executeTest(parsed);
331
- if (parsed.reportFile) {
332
- const isJson = parsed.reportFile.endsWith('.json');
333
- await writeFile(resolve(parsed.reportFile), formatDiagnostic(report, isJson ? 'json' : 'markdown'), 'utf8');
334
- }
335
- if (parsed.htmlFile) {
336
- await writeFile(resolve(parsed.htmlFile), formatHtmlReport(report), 'utf8');
337
- }
338
- if (parsed.json) {
339
- process.stdout.write(formatDiagnostic(report, 'json'));
340
- }
341
- else if (!parsed.quiet) {
342
- process.stdout.write(`${formatTerminalSummary(report)}\n`);
343
- if (!report.passed) {
344
- process.stdout.write(`\n${formatAgentDiagnostic(report)}\n`);
345
- }
346
- if (parsed.reportFile)
347
- process.stdout.write(`Report written to ${parsed.reportFile}\n`);
348
- if (parsed.htmlFile)
349
- process.stdout.write(`HTML evidence report written to ${parsed.htmlFile}\n`);
350
- }
351
- return report.passed ? 0 : 1;
352
- }
353
- case 'mcp':
354
- await startMcpServer();
355
- return 0;
356
- case 'init':
357
- return await runInit(rest);
358
- case 'doctor':
359
- return await runDoctor();
360
- case '--version':
361
- case '-v':
362
- case 'version':
363
- process.stdout.write(`ghost-qa ${VERSION}\n`);
364
- return 0;
365
- case '--help':
366
- case '-h':
367
- case 'help':
368
- case undefined:
369
- process.stdout.write(`${HELP}\n`);
370
- return 0;
371
- default:
372
- process.stderr.write(`Unknown command: ${command}\n${HELP}\n`);
373
- return 2;
374
- }
375
- }
376
- catch (error) {
377
- process.stderr.write(`\nšŸ‘» ghost-qa error: ${error instanceof Error ? error.message : String(error)}\n`);
378
- return 2;
379
- }
380
- }
381
- /**
382
- * True when this module is the process entry point. The entry path MUST be
383
- * realpath-resolved first: package managers invoke bins through a
384
- * `node_modules/.bin/ghost-qa` symlink, while `import.meta.url` always
385
- * points at the real file — comparing unresolved paths would make the CLI
386
- * silently no-op (exit 0, no output) under `npx`.
387
- */
388
- export function isMainEntry(argvEntry, moduleUrl = import.meta.url) {
389
- if (!argvEntry)
390
- return false;
391
- try {
392
- return moduleUrl === pathToFileURL(realpathSync(resolve(argvEntry))).href;
393
- }
394
- catch {
395
- return false;
396
- }
397
- }
398
- if (isMainEntry(process.argv[1])) {
399
- main().then((code) => {
400
- process.exitCode = code;
401
- }, (error) => {
402
- process.stderr.write(`ghost-qa crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
403
- process.exitCode = 2;
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 { formatAgentDiagnostic, formatDiagnostic } from '../src/reporter/agent-diagnostic.js';
10
+ import { formatHtmlReport } from '../src/reporter/html-report.js';
11
+ import { formatTerminalSummary } from '../src/reporter/terminal-summary.js';
12
+ import { DEFAULT_OPTIONS } from '../src/types.js';
13
+ export const VERSION = GHOST_QA_VERSION;
14
+ const PROTOCOL_START = '<!-- ghost-qa:start -->';
15
+ const PROTOCOL_END = '<!-- ghost-qa:end -->';
16
+ export const AGENT_PROTOCOL = `${PROTOCOL_START}
17
+ ## šŸ‘» ghost-qa — Browser Verification Protocol (MANDATORY)
18
+
19
+ Before reporting any task that touches frontend code as complete, you MUST verify it in a real browser:
20
+
21
+ 1. Ensure the app is running locally (e.g. \`npm run dev\`).
22
+ 2. Run: \`npx -y ghost-qa test <local-url>\` (e.g. \`npx -y ghost-qa test http://localhost:3000\`) — or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it. Add \`--server-cmd "npm run dev"\` to let ghost-qa manage the dev server itself.
23
+ 3. If ghost-qa reports ANY critical or major issue (hydration errors, console errors, 4xx/5xx responses, mobile overflow, accessibility or performance failures, broken interactions), fix the root cause in source and re-run.
24
+ 4. Repeat until ghost-qa prints **PASS**. Only then may you tell the user the task is done.
25
+
26
+ Never declare a UI task finished based only on code review, type-checking, or unit tests. The browser is the source of truth.
27
+ ${PROTOCOL_END}`;
28
+ const FALLBACK_SKILL = `# ghost-qa — Verify in Browser Before Done
29
+
30
+ 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.
31
+
32
+ ## The Self-Healing Loop
33
+
34
+ 1. Run the audit against the final code:
35
+ - Server already running: \`npx -y ghost-qa test <local-url>\`
36
+ - Otherwise: \`npx -y ghost-qa test <local-url> --server-cmd "npm run dev"\` (ghost-qa spawns, awaits and tears down the server)
37
+ - Or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it (identical engine).
38
+ It crawls routes on Desktop 1440x900 + Mobile 390x844 and runs the 5-Trap Engine: console/hydration, network, layout, accessibility, performance — plus safe interaction testing (never destructive controls).
39
+ 2. Read the Self-Healing Bug Report. Fix every critical/major issue by \`#id\`, at the root cause in source — never silence the checker, delete failing UI, or add suppressHydrationWarning.
40
+ 3. Re-run the identical command until it prints \`GHOST-QA PASS\`. If stuck on the same \`#id\` after 5 loops, report that honestly instead of declaring success.
41
+ 4. Only then report completion, naming the URL verified, routes crawled, interactions tested, and issues fixed.
42
+
43
+ Useful flags: \`--max-depth\`, \`--max-routes\`, \`--viewport mobile\`, \`--json\`, \`--report <file>\`, \`--html <file>\`, \`--fail-on minor\`.
44
+ If Chromium is missing: \`npx playwright install chromium\`, then \`npx -y ghost-qa doctor\`.
45
+ Full skill: https://github.com/avivgerz-hue/ghost-qa/blob/main/SKILL.md
46
+ `;
47
+ const HELP = `
48
+ šŸ‘» ghost-qa v${VERSION} — autonomous headless browser QA for coding agents
49
+
50
+ Usage:
51
+ ghost-qa test <url> [options] Crawl a running app and trap browser failures
52
+ ghost-qa init [--force] Install the agent skill, protocol + MCP config
53
+ ghost-qa mcp Run the native MCP server (JSON-RPC over stdio)
54
+ ghost-qa doctor Check Node, Playwright and Chromium setup
55
+ ghost-qa --help | --version
56
+
57
+ test options:
58
+ --max-depth <n> Link-following depth from the start URL (default ${DEFAULT_OPTIONS.maxDepth})
59
+ --max-routes <n> Maximum routes to visit (default ${DEFAULT_OPTIONS.maxRoutes})
60
+ --timeout <ms> Per-navigation timeout (default ${DEFAULT_OPTIONS.timeoutMs})
61
+ --viewport <name> both | desktop | mobile (default both)
62
+ --no-interactions Only load pages; do not click/fill anything
63
+ --headed Show the browser window (debugging)
64
+ --fail-on <severity> Fail on critical | major | minor (default ${DEFAULT_OPTIONS.failOn})
65
+ --server-cmd <cmd> Spawn this dev-server command first, wait for the URL,
66
+ run the audit, then terminate the server tree
67
+ --server-timeout <ms> How long to wait for --server-cmd to respond (default 30000)
68
+ --json Print the machine-readable JSON report to stdout
69
+ --report <file> Write the diagnostic to a file (.md or .json by extension)
70
+ --html <file> Write the visual HTML evidence dashboard to a file
71
+ --quiet Suppress the terminal summary (report/exit code only)
72
+
73
+ Examples:
74
+ npx -y ghost-qa test http://localhost:3000
75
+ npx -y ghost-qa test http://localhost:3000 --server-cmd "npm run dev"
76
+ npx -y ghost-qa test http://localhost:5173 --max-depth 3 --html ghost-qa-report.html
77
+ npx -y ghost-qa test http://localhost:3000 --json > report.json
78
+ npx -y ghost-qa init
79
+ npx -y ghost-qa mcp
80
+
81
+ No npm? Every command also works straight from GitHub, e.g.
82
+ npx -y github:avivgerz-hue/ghost-qa test http://localhost:3000
83
+ `;
84
+ function parsePositiveInt(value, flag) {
85
+ const parsed = Number.parseInt(value ?? '', 10);
86
+ if (!Number.isFinite(parsed) || parsed <= 0) {
87
+ throw new Error(`${flag} expects a positive integer, got "${value ?? ''}"`);
88
+ }
89
+ return parsed;
90
+ }
91
+ function parseNonNegativeInt(value, flag) {
92
+ const parsed = Number.parseInt(value ?? '', 10);
93
+ if (!Number.isFinite(parsed) || parsed < 0) {
94
+ throw new Error(`${flag} expects a non-negative integer, got "${value ?? ''}"`);
95
+ }
96
+ return parsed;
97
+ }
98
+ export function parseTestArgs(args) {
99
+ const options = { ...DEFAULT_OPTIONS, viewports: [...DEFAULT_OPTIONS.viewports] };
100
+ let target = '';
101
+ let json = false;
102
+ let quiet = false;
103
+ let reportFile;
104
+ let htmlFile;
105
+ let serverCmd;
106
+ let serverTimeoutMs = 30_000;
107
+ for (let i = 0; i < args.length; i++) {
108
+ const arg = args[i];
109
+ switch (arg) {
110
+ case '--max-depth':
111
+ options.maxDepth = parseNonNegativeInt(args[++i], arg);
112
+ break;
113
+ case '--max-routes':
114
+ options.maxRoutes = parsePositiveInt(args[++i], arg);
115
+ break;
116
+ case '--timeout':
117
+ options.timeoutMs = parsePositiveInt(args[++i], arg);
118
+ break;
119
+ case '--viewport': {
120
+ const value = args[++i];
121
+ if (value === 'desktop' || value === 'mobile')
122
+ options.viewports = [value];
123
+ else if (value === 'both')
124
+ options.viewports = ['desktop', 'mobile'];
125
+ else
126
+ throw new Error(`--viewport expects both|desktop|mobile, got "${value ?? ''}"`);
127
+ break;
128
+ }
129
+ case '--no-interactions':
130
+ options.interactions = false;
131
+ break;
132
+ case '--headed':
133
+ options.headless = false;
134
+ break;
135
+ case '--fail-on': {
136
+ const value = args[++i];
137
+ if (value === 'critical' || value === 'major' || value === 'minor')
138
+ options.failOn = value;
139
+ else
140
+ throw new Error(`--fail-on expects critical|major|minor, got "${value ?? ''}"`);
141
+ break;
142
+ }
143
+ case '--json':
144
+ json = true;
145
+ break;
146
+ case '--quiet':
147
+ quiet = true;
148
+ break;
149
+ case '--report':
150
+ reportFile = args[++i];
151
+ if (!reportFile)
152
+ throw new Error('--report expects a file path');
153
+ break;
154
+ case '--html':
155
+ htmlFile = args[++i];
156
+ if (!htmlFile)
157
+ throw new Error('--html expects a file path');
158
+ break;
159
+ case '--server-cmd':
160
+ serverCmd = args[++i];
161
+ if (!serverCmd)
162
+ throw new Error('--server-cmd expects a command, e.g. --server-cmd "npm run dev"');
163
+ break;
164
+ case '--server-timeout':
165
+ serverTimeoutMs = parsePositiveInt(args[++i], arg);
166
+ break;
167
+ default:
168
+ if (arg.startsWith('--'))
169
+ throw new Error(`Unknown flag: ${arg}`);
170
+ if (!target)
171
+ target = arg;
172
+ else
173
+ throw new Error(`Unexpected extra argument: ${arg}`);
174
+ }
175
+ }
176
+ if (!target)
177
+ throw new Error('Missing target URL. Usage: ghost-qa test <url>');
178
+ return {
179
+ target,
180
+ options,
181
+ json,
182
+ quiet,
183
+ serverTimeoutMs,
184
+ ...(reportFile ? { reportFile } : {}),
185
+ ...(htmlFile ? { htmlFile } : {}),
186
+ ...(serverCmd ? { serverCmd } : {}),
187
+ };
188
+ }
189
+ async function executeTest(parsed) {
190
+ const audit = () => runGhostQAAudit(parsed.target, parsed.options);
191
+ if (!parsed.serverCmd)
192
+ return audit();
193
+ const server = new ManagedDevServer(parsed.serverCmd);
194
+ process.stderr.write(`\nšŸ‘» ghost-qa: starting dev server: ${parsed.serverCmd}\n`);
195
+ server.start();
196
+ try {
197
+ await server.waitUntilReady(parsed.target, parsed.serverTimeoutMs);
198
+ process.stderr.write(`šŸ‘» ghost-qa: server is responding at ${parsed.target}\n`);
199
+ return await audit();
200
+ }
201
+ finally {
202
+ await server.stop();
203
+ process.stderr.write('šŸ‘» ghost-qa: dev server terminated.\n');
204
+ }
205
+ }
206
+ async function loadSkillTemplate() {
207
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
208
+ const candidates = [
209
+ resolve(moduleDir, '../../SKILL.md'), // dist/bin -> package root
210
+ resolve(moduleDir, '../SKILL.md'),
211
+ resolve(process.cwd(), 'SKILL.md'),
212
+ ];
213
+ for (const candidate of candidates) {
214
+ try {
215
+ const content = await readFile(candidate, 'utf8');
216
+ if (content.includes('ghost-qa'))
217
+ return content;
218
+ }
219
+ catch {
220
+ /* try next candidate */
221
+ }
222
+ }
223
+ return FALLBACK_SKILL;
224
+ }
225
+ async function upsertProtocol(filePath) {
226
+ let existing = '';
227
+ try {
228
+ existing = await readFile(filePath, 'utf8');
229
+ }
230
+ catch {
231
+ await writeFile(filePath, `# Agent Instructions\n\n${AGENT_PROTOCOL}\n`, 'utf8');
232
+ return 'created';
233
+ }
234
+ const start = existing.indexOf(PROTOCOL_START);
235
+ const end = existing.indexOf(PROTOCOL_END);
236
+ if (start !== -1 && end !== -1 && end > start) {
237
+ const next = `${existing.slice(0, start)}${AGENT_PROTOCOL}${existing.slice(end + PROTOCOL_END.length)}`;
238
+ if (next === existing)
239
+ return 'unchanged';
240
+ await writeFile(filePath, next, 'utf8');
241
+ return 'updated';
242
+ }
243
+ await writeFile(filePath, `${existing.replace(/\s*$/, '')}\n\n${AGENT_PROTOCOL}\n`, 'utf8');
244
+ return 'updated';
245
+ }
246
+ export async function installSkill(targetDir, force = false) {
247
+ const results = [];
248
+ const skillPath = join(targetDir, '.claude', 'skills', 'ghost-qa', 'SKILL.md');
249
+ await mkdir(dirname(skillPath), { recursive: true });
250
+ const skillContent = await loadSkillTemplate();
251
+ const skillExisted = existsSync(skillPath);
252
+ if (!skillExisted || force) {
253
+ await writeFile(skillPath, skillContent.endsWith('\n') ? skillContent : `${skillContent}\n`, 'utf8');
254
+ results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: skillExisted ? 'updated (--force)' : 'installed' });
255
+ }
256
+ else {
257
+ results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: 'already exists (use --force to overwrite)' });
258
+ }
259
+ for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
260
+ const action = await upsertProtocol(join(targetDir, name));
261
+ results.push({ file: name, action: `protocol ${action}` });
262
+ }
263
+ const mcpAction = await installMcpConfig(join(targetDir, '.mcp.json'), force);
264
+ results.push({ file: '.mcp.json', action: mcpAction });
265
+ return results;
266
+ }
267
+ /**
268
+ * Writes (or merges into) `.mcp.json`, preserving any MCP servers the
269
+ * project already configured. The ghost-qa entry is always refreshed so
270
+ * hosts invoke the published CLI: `npx -y ghost-qa mcp`.
271
+ */
272
+ export async function installMcpConfig(mcpJsonPath, force = false) {
273
+ const desired = mcpServerConfig();
274
+ let existing = {};
275
+ let action = 'installed';
276
+ try {
277
+ const raw = await readFile(mcpJsonPath, 'utf8');
278
+ const parsed = JSON.parse(raw);
279
+ if (parsed && typeof parsed === 'object') {
280
+ existing = parsed;
281
+ action = 'merged';
282
+ }
283
+ }
284
+ catch {
285
+ action = 'installed';
286
+ }
287
+ const servers = (existing.mcpServers ?? {});
288
+ const previous = servers['ghost-qa'];
289
+ if (previous && !force && JSON.stringify(previous) === JSON.stringify(desired.mcpServers['ghost-qa'])) {
290
+ return 'already configured';
291
+ }
292
+ servers['ghost-qa'] = desired.mcpServers['ghost-qa'];
293
+ existing.mcpServers = servers;
294
+ await writeFile(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
295
+ return previous ? 'updated' : action;
296
+ }
297
+ async function runInit(args) {
298
+ const force = args.includes('--force');
299
+ const dirFlagIndex = args.indexOf('--dir');
300
+ const targetDir = dirFlagIndex !== -1 ? resolve(args[dirFlagIndex + 1] ?? '.') : process.cwd();
301
+ const results = await installSkill(targetDir, force);
302
+ process.stdout.write(`\nšŸ‘» ghost-qa agent skill installed in ${targetDir}\n`);
303
+ for (const result of results) {
304
+ process.stdout.write(` • ${result.file} — ${result.action}\n`);
305
+ }
306
+ 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' +
307
+ 'MCP hosts (Claude Code, Claude Desktop, Cursor, Codex) can also call the ghost_qa_audit_url / ghost_qa_doctor tools directly via the .mcp.json entry above.\n');
308
+ return 0;
309
+ }
310
+ async function runDoctor() {
311
+ process.stdout.write(`\nšŸ‘» ghost-qa doctor v${VERSION}\n`);
312
+ const result = await runDoctorCheck();
313
+ process.stdout.write(` ${result.nodeOk ? 'āœ”' : 'āœ–'} Node.js ${result.node} ${result.nodeOk ? '' : '(requires >= 20)'}\n`);
314
+ if (result.chromiumFound) {
315
+ process.stdout.write(` āœ” Chromium binary: ${result.chromiumPath}\n`);
316
+ const smokeOk = result.smokeTest.startsWith('Chromium launched');
317
+ process.stdout.write(` ${smokeOk ? 'āœ”' : 'āœ–'} Browser smoke test: ${result.smokeTest}\n`);
318
+ }
319
+ else {
320
+ process.stdout.write(' āœ– Chromium binary not found. Run: npx playwright install chromium\n');
321
+ }
322
+ process.stdout.write(result.ready
323
+ ? '\nāœ” ghost-qa is ready: npx -y ghost-qa test http://localhost:3000\n'
324
+ : '\nāœ– Setup incomplete — fix the items above.\n');
325
+ return result.ready ? 0 : 1;
326
+ }
327
+ export async function main(argv = process.argv.slice(2)) {
328
+ const [command, ...rest] = argv;
329
+ try {
330
+ switch (command) {
331
+ case 'test': {
332
+ const parsed = parseTestArgs(rest);
333
+ const report = await executeTest(parsed);
334
+ if (parsed.reportFile) {
335
+ const isJson = parsed.reportFile.endsWith('.json');
336
+ await writeFile(resolve(parsed.reportFile), formatDiagnostic(report, isJson ? 'json' : 'markdown'), 'utf8');
337
+ }
338
+ if (parsed.htmlFile) {
339
+ await writeFile(resolve(parsed.htmlFile), formatHtmlReport(report), 'utf8');
340
+ }
341
+ if (parsed.json) {
342
+ process.stdout.write(formatDiagnostic(report, 'json'));
343
+ }
344
+ else if (!parsed.quiet) {
345
+ process.stdout.write(`${formatTerminalSummary(report)}\n`);
346
+ if (!report.passed) {
347
+ process.stdout.write(`\n${formatAgentDiagnostic(report)}\n`);
348
+ }
349
+ if (parsed.reportFile)
350
+ process.stdout.write(`Report written to ${parsed.reportFile}\n`);
351
+ if (parsed.htmlFile)
352
+ process.stdout.write(`HTML evidence report written to ${parsed.htmlFile}\n`);
353
+ }
354
+ return report.passed ? 0 : 1;
355
+ }
356
+ case 'mcp':
357
+ await startMcpServer();
358
+ return 0;
359
+ case 'init':
360
+ return await runInit(rest);
361
+ case 'doctor':
362
+ return await runDoctor();
363
+ case '--version':
364
+ case '-v':
365
+ case 'version':
366
+ process.stdout.write(`ghost-qa ${VERSION}\n`);
367
+ return 0;
368
+ case '--help':
369
+ case '-h':
370
+ case 'help':
371
+ case undefined:
372
+ process.stdout.write(`${HELP}\n`);
373
+ return 0;
374
+ default:
375
+ process.stderr.write(`Unknown command: ${command}\n${HELP}\n`);
376
+ return 2;
377
+ }
378
+ }
379
+ catch (error) {
380
+ process.stderr.write(`\nšŸ‘» ghost-qa error: ${error instanceof Error ? error.message : String(error)}\n`);
381
+ return 2;
382
+ }
383
+ }
384
+ /**
385
+ * True when this module is the process entry point. The entry path MUST be
386
+ * realpath-resolved first: package managers invoke bins through a
387
+ * `node_modules/.bin/ghost-qa` symlink, while `import.meta.url` always
388
+ * points at the real file — comparing unresolved paths would make the CLI
389
+ * silently no-op (exit 0, no output) under `npx`.
390
+ */
391
+ export function isMainEntry(argvEntry, moduleUrl = import.meta.url) {
392
+ if (!argvEntry)
393
+ return false;
394
+ try {
395
+ return moduleUrl === pathToFileURL(realpathSync(resolve(argvEntry))).href;
396
+ }
397
+ catch {
398
+ return false;
399
+ }
400
+ }
401
+ if (isMainEntry(process.argv[1])) {
402
+ main().then((code) => {
403
+ process.exitCode = code;
404
+ }, (error) => {
405
+ process.stderr.write(`ghost-qa crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
406
+ process.exitCode = 2;
407
+ });
408
+ }
406
409
  //# sourceMappingURL=ghost-qa.js.map