ghost-qa 1.3.0 → 1.3.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.
- package/dist/bin/ghost-qa.js +405 -405
- package/package.json +91 -91
package/dist/bin/ghost-qa.js
CHANGED
|
@@ -1,406 +1,406 @@
|
|
|
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 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
|
+
}
|
|
406
406
|
//# sourceMappingURL=ghost-qa.js.map
|
package/package.json
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ghost-qa",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "Autonomous headless browser QA & self-healing verification loop for coding agents. Crawls localhost, traps console/network/layout failures, and feeds structured diagnostics back to the agent.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"ghost-qa": "
|
|
8
|
-
},
|
|
9
|
-
"main": "./dist/src/index.js",
|
|
10
|
-
"types": "./dist/src/index.d.ts",
|
|
11
|
-
"exports": {
|
|
12
|
-
".": {
|
|
13
|
-
"types": "./dist/src/index.d.ts",
|
|
14
|
-
"import": "./dist/src/index.js"
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
"files": [
|
|
18
|
-
"dist",
|
|
19
|
-
"scripts/prepare-build.mjs",
|
|
20
|
-
"SKILL.md",
|
|
21
|
-
"README.md",
|
|
22
|
-
"LICENSE"
|
|
23
|
-
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"prepare": "node scripts/prepare-build.mjs",
|
|
26
|
-
"build": "tsc -p tsconfig.json",
|
|
27
|
-
"dev": "tsc -p tsconfig.json --watch",
|
|
28
|
-
"test": "vitest run",
|
|
29
|
-
"test:watch": "vitest",
|
|
30
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
-
"qa": "npm run build && npm test",
|
|
32
|
-
"prepublishOnly": "npm run qa"
|
|
33
|
-
},
|
|
34
|
-
"keywords": [
|
|
35
|
-
"mcp",
|
|
36
|
-
"mcp-server",
|
|
37
|
-
"model-context-protocol",
|
|
38
|
-
"claude-code",
|
|
39
|
-
"claude-skills",
|
|
40
|
-
"agent-skills",
|
|
41
|
-
"ai-agents",
|
|
42
|
-
"coding-agents",
|
|
43
|
-
"playwright",
|
|
44
|
-
"headless-browser",
|
|
45
|
-
"qa-automation",
|
|
46
|
-
"e2e-testing",
|
|
47
|
-
"self-healing",
|
|
48
|
-
"codex",
|
|
49
|
-
"cursor",
|
|
50
|
-
"cursorrules",
|
|
51
|
-
"windsurf",
|
|
52
|
-
"accessibility-testing",
|
|
53
|
-
"a11y",
|
|
54
|
-
"web-vitals",
|
|
55
|
-
"performance-testing",
|
|
56
|
-
"browser-testing",
|
|
57
|
-
"test-automation",
|
|
58
|
-
"automated-testing",
|
|
59
|
-
"typescript",
|
|
60
|
-
"cli",
|
|
61
|
-
"devtools",
|
|
62
|
-
"hydration",
|
|
63
|
-
"ci-cd",
|
|
64
|
-
"browser-verification",
|
|
65
|
-
"agent-tools",
|
|
66
|
-
"llm-agents",
|
|
67
|
-
"frontend-qa",
|
|
68
|
-
"mobile-testing"
|
|
69
|
-
],
|
|
70
|
-
"author": "VibeFoundry",
|
|
71
|
-
"license": "MIT",
|
|
72
|
-
"repository": {
|
|
73
|
-
"type": "git",
|
|
74
|
-
"url": "git+https://github.com/avivgerz-hue/ghost-qa.git"
|
|
75
|
-
},
|
|
76
|
-
"homepage": "https://github.com/avivgerz-hue/ghost-qa#readme",
|
|
77
|
-
"bugs": {
|
|
78
|
-
"url": "https://github.com/avivgerz-hue/ghost-qa/issues"
|
|
79
|
-
},
|
|
80
|
-
"engines": {
|
|
81
|
-
"node": ">=20"
|
|
82
|
-
},
|
|
83
|
-
"dependencies": {
|
|
84
|
-
"playwright": "^1.49.1"
|
|
85
|
-
},
|
|
86
|
-
"devDependencies": {
|
|
87
|
-
"@types/node": "^22.10.2",
|
|
88
|
-
"typescript": "^5.7.2",
|
|
89
|
-
"vitest": "^2.1.8"
|
|
90
|
-
}
|
|
91
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ghost-qa",
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Autonomous headless browser QA & self-healing verification loop for coding agents. Crawls localhost, traps console/network/layout failures, and feeds structured diagnostics back to the agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ghost-qa": "dist/bin/ghost-qa.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/src/index.js",
|
|
10
|
+
"types": "./dist/src/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/src/index.d.ts",
|
|
14
|
+
"import": "./dist/src/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"scripts/prepare-build.mjs",
|
|
20
|
+
"SKILL.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepare": "node scripts/prepare-build.mjs",
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"qa": "npm run build && npm test",
|
|
32
|
+
"prepublishOnly": "npm run qa"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"mcp",
|
|
36
|
+
"mcp-server",
|
|
37
|
+
"model-context-protocol",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"claude-skills",
|
|
40
|
+
"agent-skills",
|
|
41
|
+
"ai-agents",
|
|
42
|
+
"coding-agents",
|
|
43
|
+
"playwright",
|
|
44
|
+
"headless-browser",
|
|
45
|
+
"qa-automation",
|
|
46
|
+
"e2e-testing",
|
|
47
|
+
"self-healing",
|
|
48
|
+
"codex",
|
|
49
|
+
"cursor",
|
|
50
|
+
"cursorrules",
|
|
51
|
+
"windsurf",
|
|
52
|
+
"accessibility-testing",
|
|
53
|
+
"a11y",
|
|
54
|
+
"web-vitals",
|
|
55
|
+
"performance-testing",
|
|
56
|
+
"browser-testing",
|
|
57
|
+
"test-automation",
|
|
58
|
+
"automated-testing",
|
|
59
|
+
"typescript",
|
|
60
|
+
"cli",
|
|
61
|
+
"devtools",
|
|
62
|
+
"hydration",
|
|
63
|
+
"ci-cd",
|
|
64
|
+
"browser-verification",
|
|
65
|
+
"agent-tools",
|
|
66
|
+
"llm-agents",
|
|
67
|
+
"frontend-qa",
|
|
68
|
+
"mobile-testing"
|
|
69
|
+
],
|
|
70
|
+
"author": "VibeFoundry",
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"repository": {
|
|
73
|
+
"type": "git",
|
|
74
|
+
"url": "git+https://github.com/avivgerz-hue/ghost-qa.git"
|
|
75
|
+
},
|
|
76
|
+
"homepage": "https://github.com/avivgerz-hue/ghost-qa#readme",
|
|
77
|
+
"bugs": {
|
|
78
|
+
"url": "https://github.com/avivgerz-hue/ghost-qa/issues"
|
|
79
|
+
},
|
|
80
|
+
"engines": {
|
|
81
|
+
"node": ">=20"
|
|
82
|
+
},
|
|
83
|
+
"dependencies": {
|
|
84
|
+
"playwright": "^1.49.1"
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@types/node": "^22.10.2",
|
|
88
|
+
"typescript": "^5.7.2",
|
|
89
|
+
"vitest": "^2.1.8"
|
|
90
|
+
}
|
|
91
|
+
}
|