comfy-qa 1.0.0 → 1.2.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/README.md +73 -6
- package/package.json +18 -3
- package/src/agent/browser-agent.ts +298 -0
- package/src/agent/demo-editor.ts +450 -0
- package/src/agent/demo-research.ts +725 -0
- package/src/agent/orchestrator.ts +268 -0
- package/src/agent/qa-research.ts +813 -0
- package/src/agent/research.ts +221 -0
- package/src/browser/hud.ts +136 -0
- package/src/browser/recorder.ts +131 -0
- package/src/cli.ts +69 -28
- package/src/commands/full.ts +40 -0
- package/src/commands/issue.ts +23 -0
- package/src/commands/pr.ts +23 -0
- package/src/commands/setup.ts +46 -0
- package/src/recorder/narration.ts +176 -0
- package/src/recorder/post-mix.ts +81 -0
- package/src/report/e2e-test.ts +132 -0
- package/src/report/generate.ts +271 -0
- package/src/utils/comfyui.ts +349 -0
- package/src/utils/github.ts +87 -0
- package/src/utils/parse-url.ts +11 -0
- package/src/utils/qa-skill.ts +376 -0
package/README.md
CHANGED
|
@@ -1,15 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# comfy-qa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
E2E QA automation for user-facing frontend repos. AI-driven Playwright tests with video recording, HUD overlay, and structured reports.
|
|
4
|
+
|
|
5
|
+
## One-shot setup
|
|
6
|
+
|
|
7
|
+
Tell your AI agent (Claude Code, Cursor, etc.):
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
run npx cmqa setup
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The agent reads the emitted prompt and automatically:
|
|
14
|
+
|
|
15
|
+
- Detects your framework, package manager, backend, and auth
|
|
16
|
+
- Installs Playwright
|
|
17
|
+
- Creates `playwright.qa.config.ts` with video/trace/screenshot enabled
|
|
18
|
+
- Creates `.claude/skills/comfy-qa/SKILL.md` tailored to your repo
|
|
19
|
+
- Creates `.claude/skills/comfy-qa/REPRODUCE.md` for issue reproduction
|
|
20
|
+
- Creates starter `tests/e2e/qa.spec.ts` covering your key routes
|
|
21
|
+
- Updates `.gitignore`
|
|
22
|
+
|
|
23
|
+
Re-running `npx cmqa setup` updates existing files without overwriting what's already correct.
|
|
24
|
+
|
|
25
|
+
## QA a PR or issue
|
|
4
26
|
|
|
5
27
|
```bash
|
|
6
|
-
|
|
28
|
+
# Paste a GitHub URL — auto-detects PR vs issue
|
|
29
|
+
cmqa https://github.com/org/repo/pull/123
|
|
30
|
+
cmqa https://github.com/org/repo/issues/456
|
|
31
|
+
|
|
32
|
+
# Or use subcommands
|
|
33
|
+
cmqa pr https://github.com/org/repo/pull/123
|
|
34
|
+
cmqa issue org/repo#456
|
|
35
|
+
|
|
36
|
+
# Batch QA recent open issues
|
|
37
|
+
cmqa full org/repo --limit 5
|
|
7
38
|
```
|
|
8
39
|
|
|
9
|
-
|
|
40
|
+
Each run produces in `.comfy-qa/<slug>/`:
|
|
41
|
+
|
|
42
|
+
| File | Content |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `report.md` | Full QA report — bug analysis, checklist, test scenarios |
|
|
45
|
+
| `qa-sheet.md` | Printable QA checklist for manual testing |
|
|
46
|
+
| `<type>-<N>.e2e.ts` | Generated Playwright E2E test |
|
|
47
|
+
| `qa-<N>.webm` | Recorded session video with HUD overlay |
|
|
48
|
+
| `screenshots/` | Step-by-step screenshots |
|
|
49
|
+
| `research.json` | Raw research data |
|
|
50
|
+
| `agent-log.txt` | Agent action log |
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
--no-record Disable video recording (ON by default)
|
|
56
|
+
--add-comment Post report as GitHub comment
|
|
57
|
+
--comfy-url <url> Point to a running dev server (default: auto-detect)
|
|
58
|
+
--limit N Number of issues for batch mode (default: 5)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
1. **Research** — Fetches PR/issue from GitHub, Claude analyzes bug/feature and generates QA checklist + test scenarios
|
|
64
|
+
2. **Record** — Playwright opens the app with a HUD overlay showing what the agent is doing. If the dev server is running, an AI agent drives the browser through each test scenario
|
|
65
|
+
3. **Report** — Generates structured markdown report, QA sheet, E2E test file, and video
|
|
66
|
+
|
|
67
|
+
### Backend strategy
|
|
68
|
+
|
|
69
|
+
No mocks. QA runs against real servers:
|
|
70
|
+
|
|
71
|
+
- If the QA target is **unrelated to the backend** — use the repo's default staging server
|
|
72
|
+
- If the QA target **is related to the backend** — clone the backend to `tmp/`, build, run locally, point the frontend to `localhost`
|
|
73
|
+
|
|
74
|
+
## Install
|
|
10
75
|
|
|
11
76
|
```bash
|
|
12
|
-
bun
|
|
77
|
+
bun install
|
|
13
78
|
```
|
|
14
79
|
|
|
15
|
-
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comfy-qa",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "ComfyUI QA automation CLI",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/Comfy-Org/Comfy-QA"
|
|
8
|
+
},
|
|
5
9
|
"module": "src/index.ts",
|
|
6
10
|
"main": "src/index.ts",
|
|
7
11
|
"type": "module",
|
|
8
12
|
"bin": {
|
|
9
|
-
"comfy-qa": "./src/cli.ts"
|
|
13
|
+
"comfy-qa": "./src/cli.ts",
|
|
14
|
+
"cmqa": "./src/cli.ts"
|
|
10
15
|
},
|
|
11
16
|
"files": [
|
|
12
17
|
"src/"
|
|
@@ -18,12 +23,22 @@
|
|
|
18
23
|
"start": "bun src/cli.ts",
|
|
19
24
|
"dev": "bun --hot src/cli.ts",
|
|
20
25
|
"test": "bun test",
|
|
21
|
-
"build": "bun build src/cli.ts --compile --outfile dist/comfy-qa"
|
|
26
|
+
"build": "bun build src/cli.ts --compile --outfile dist/comfy-qa",
|
|
27
|
+
"prepare": "git config core.hooksPath .githooks && git submodule update --init --remote && cd lib/demowright && bun install && bun run build",
|
|
28
|
+
"postinstall": "git submodule update --init --remote && cd lib/demowright && bun install && bun run build"
|
|
22
29
|
},
|
|
23
30
|
"devDependencies": {
|
|
31
|
+
"@playwright/test": "^1.59.1",
|
|
24
32
|
"@types/bun": "latest"
|
|
25
33
|
},
|
|
26
34
|
"peerDependencies": {
|
|
27
35
|
"typescript": "^5"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@anthropic-ai/sdk": "^0.82.0",
|
|
39
|
+
"ffmpeg-static": "^5.3.0",
|
|
40
|
+
"playwright": "^1.59.1",
|
|
41
|
+
"yaml": "^2.8.3",
|
|
42
|
+
"zod": "^4.3.6"
|
|
28
43
|
}
|
|
29
44
|
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { RecorderSession } from "../browser/recorder";
|
|
3
|
+
import type { TestScenario, QAChecklistItem } from "./research";
|
|
4
|
+
|
|
5
|
+
/** An action the AI agent decides to take */
|
|
6
|
+
interface AgentAction {
|
|
7
|
+
type: "click" | "type" | "scroll" | "hover" | "wait" | "key" | "drag" | "screenshot" | "evaluate" | "done";
|
|
8
|
+
selector?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
x?: number;
|
|
11
|
+
y?: number;
|
|
12
|
+
key?: string;
|
|
13
|
+
ms?: number;
|
|
14
|
+
code?: string;
|
|
15
|
+
observation?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Get page accessibility tree snapshot for the agent to reason about */
|
|
19
|
+
async function getA11ySnapshot(page: Page): Promise<string> {
|
|
20
|
+
try {
|
|
21
|
+
const tree = await page.accessibility.snapshot();
|
|
22
|
+
return tree ? formatA11yTree(tree, 0) : "(empty a11y tree)";
|
|
23
|
+
} catch {
|
|
24
|
+
return "(a11y tree unavailable)";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatA11yTree(node: any, depth: number): string {
|
|
29
|
+
const indent = " ".repeat(depth);
|
|
30
|
+
let line = `${indent}[${node.role}]`;
|
|
31
|
+
if (node.name) line += ` "${node.name}"`;
|
|
32
|
+
if (node.value) line += ` value="${node.value}"`;
|
|
33
|
+
if (node.checked !== undefined) line += ` checked=${node.checked}`;
|
|
34
|
+
if (node.pressed !== undefined) line += ` pressed=${node.pressed}`;
|
|
35
|
+
let result = line + "\n";
|
|
36
|
+
if (node.children) {
|
|
37
|
+
for (const child of node.children.slice(0, 50)) {
|
|
38
|
+
result += formatA11yTree(child, depth + 1);
|
|
39
|
+
}
|
|
40
|
+
if (node.children.length > 50) {
|
|
41
|
+
result += `${indent} ... (${node.children.length - 50} more)\n`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Take a screenshot and encode it as base64 for the AI agent */
|
|
48
|
+
async function capturePageState(page: Page): Promise<{
|
|
49
|
+
screenshot: string;
|
|
50
|
+
a11yTree: string;
|
|
51
|
+
url: string;
|
|
52
|
+
title: string;
|
|
53
|
+
consoleErrors: string[];
|
|
54
|
+
}> {
|
|
55
|
+
const screenshotBuffer = await page.screenshot({ type: "png" });
|
|
56
|
+
const screenshot = screenshotBuffer.toString("base64");
|
|
57
|
+
const a11yTree = await getA11ySnapshot(page);
|
|
58
|
+
const url = page.url();
|
|
59
|
+
const title = await page.title();
|
|
60
|
+
|
|
61
|
+
return { screenshot, a11yTree, url, title, consoleErrors: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ask Claude to decide the next action based on the current page state */
|
|
65
|
+
async function askAgentForAction(
|
|
66
|
+
scenario: TestScenario,
|
|
67
|
+
stepIndex: number,
|
|
68
|
+
pageState: { screenshot: string; a11yTree: string; url: string; title: string },
|
|
69
|
+
history: string[]
|
|
70
|
+
): Promise<AgentAction[]> {
|
|
71
|
+
const prompt = `You are a QA automation agent controlling a browser via Playwright to test ComfyUI.
|
|
72
|
+
|
|
73
|
+
## Current Scenario: ${scenario.name}
|
|
74
|
+
${scenario.description}
|
|
75
|
+
|
|
76
|
+
## Test Steps
|
|
77
|
+
${scenario.steps.map((s, i) => `${i === stepIndex ? "👉 " : " "}${i + 1}. ${s}`).join("\n")}
|
|
78
|
+
|
|
79
|
+
## Current Step: ${stepIndex + 1}/${scenario.steps.length}
|
|
80
|
+
"${scenario.steps[stepIndex]}"
|
|
81
|
+
|
|
82
|
+
## Expected Outcome
|
|
83
|
+
${scenario.expectedOutcome}
|
|
84
|
+
|
|
85
|
+
## Playwright Hint
|
|
86
|
+
${scenario.playwrightHint}
|
|
87
|
+
|
|
88
|
+
## Page State
|
|
89
|
+
- URL: ${pageState.url}
|
|
90
|
+
- Title: ${pageState.title}
|
|
91
|
+
|
|
92
|
+
## Accessibility Tree (truncated)
|
|
93
|
+
${pageState.a11yTree.slice(0, 3000)}
|
|
94
|
+
|
|
95
|
+
## Action History
|
|
96
|
+
${history.slice(-10).join("\n") || "(start)"}
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
Return a JSON array of 1-5 actions to execute for this step. Each action:
|
|
101
|
+
{
|
|
102
|
+
"type": "click" | "type" | "scroll" | "hover" | "wait" | "key" | "done",
|
|
103
|
+
"selector": "CSS selector or text content to target",
|
|
104
|
+
"text": "text to type (for type action)",
|
|
105
|
+
"x": number, "y": number (for coordinate-based click),
|
|
106
|
+
"key": "key name (for key action, e.g. Enter, Tab)",
|
|
107
|
+
"ms": milliseconds (for wait action),
|
|
108
|
+
"observation": "what you expect to see / what you observed"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Use "done" when the current step is complete and we should move to the next step.
|
|
112
|
+
If ComfyUI is not loaded or the page shows something unexpected, include an observation explaining what you see.
|
|
113
|
+
Return ONLY the JSON array.`;
|
|
114
|
+
|
|
115
|
+
const proc = Bun.spawn(["claude", "--print", "--model", "claude-sonnet-4-6"], {
|
|
116
|
+
stdin: new TextEncoder().encode(prompt),
|
|
117
|
+
stdout: "pipe",
|
|
118
|
+
stderr: "pipe",
|
|
119
|
+
});
|
|
120
|
+
const output = await new Response(proc.stdout).text();
|
|
121
|
+
await proc.exited;
|
|
122
|
+
|
|
123
|
+
const jsonMatch = output.match(/\[[\s\S]*\]/);
|
|
124
|
+
if (!jsonMatch) {
|
|
125
|
+
return [{ type: "done", observation: `Agent could not parse response: ${output.slice(0, 200)}` }];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(jsonMatch[0]) as AgentAction[];
|
|
130
|
+
} catch {
|
|
131
|
+
return [{ type: "done", observation: "Failed to parse agent actions" }];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Execute a single agent action on the page */
|
|
136
|
+
async function executeAction(page: Page, action: AgentAction): Promise<string> {
|
|
137
|
+
try {
|
|
138
|
+
switch (action.type) {
|
|
139
|
+
case "click":
|
|
140
|
+
if (action.x !== undefined && action.y !== undefined) {
|
|
141
|
+
await page.mouse.click(action.x, action.y);
|
|
142
|
+
return `Clicked at (${action.x}, ${action.y})`;
|
|
143
|
+
}
|
|
144
|
+
if (action.selector) {
|
|
145
|
+
await page.click(action.selector, { timeout: 5000 });
|
|
146
|
+
return `Clicked: ${action.selector}`;
|
|
147
|
+
}
|
|
148
|
+
return "Click: no target specified";
|
|
149
|
+
|
|
150
|
+
case "type":
|
|
151
|
+
if (action.selector && action.text) {
|
|
152
|
+
await page.fill(action.selector, action.text, { timeout: 5000 });
|
|
153
|
+
return `Typed "${action.text}" into ${action.selector}`;
|
|
154
|
+
}
|
|
155
|
+
if (action.text) {
|
|
156
|
+
await page.keyboard.type(action.text);
|
|
157
|
+
return `Typed: "${action.text}"`;
|
|
158
|
+
}
|
|
159
|
+
return "Type: no text specified";
|
|
160
|
+
|
|
161
|
+
case "scroll":
|
|
162
|
+
const dy = action.y ?? 300;
|
|
163
|
+
await page.mouse.wheel(0, dy);
|
|
164
|
+
return `Scrolled by ${dy}px`;
|
|
165
|
+
|
|
166
|
+
case "hover":
|
|
167
|
+
if (action.selector) {
|
|
168
|
+
await page.hover(action.selector, { timeout: 5000 });
|
|
169
|
+
return `Hovered: ${action.selector}`;
|
|
170
|
+
}
|
|
171
|
+
if (action.x !== undefined && action.y !== undefined) {
|
|
172
|
+
await page.mouse.move(action.x, action.y);
|
|
173
|
+
return `Hovered at (${action.x}, ${action.y})`;
|
|
174
|
+
}
|
|
175
|
+
return "Hover: no target";
|
|
176
|
+
|
|
177
|
+
case "wait":
|
|
178
|
+
await page.waitForTimeout(action.ms ?? 1000);
|
|
179
|
+
return `Waited ${action.ms ?? 1000}ms`;
|
|
180
|
+
|
|
181
|
+
case "key":
|
|
182
|
+
if (action.key) {
|
|
183
|
+
await page.keyboard.press(action.key);
|
|
184
|
+
return `Pressed key: ${action.key}`;
|
|
185
|
+
}
|
|
186
|
+
return "Key: no key specified";
|
|
187
|
+
|
|
188
|
+
case "done":
|
|
189
|
+
return `Step done: ${action.observation ?? ""}`;
|
|
190
|
+
|
|
191
|
+
default:
|
|
192
|
+
return `Unknown action: ${action.type}`;
|
|
193
|
+
}
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
return `Action failed: ${err.message?.slice(0, 150)}`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Run a full test scenario with AI-driven browser automation */
|
|
200
|
+
export async function runScenarioWithAgent(
|
|
201
|
+
session: RecorderSession,
|
|
202
|
+
scenario: TestScenario,
|
|
203
|
+
scenarioIndex: number,
|
|
204
|
+
maxActionsPerStep = 10,
|
|
205
|
+
maxStepRetries = 2
|
|
206
|
+
): Promise<{ success: boolean; log: string[] }> {
|
|
207
|
+
const log: string[] = [];
|
|
208
|
+
|
|
209
|
+
await session.step(`Scenario ${scenarioIndex + 1}: ${scenario.name}`);
|
|
210
|
+
await session.plan(scenario.description);
|
|
211
|
+
log.push(`=== Scenario: ${scenario.name} ===`);
|
|
212
|
+
|
|
213
|
+
for (let stepIdx = 0; stepIdx < scenario.steps.length; stepIdx++) {
|
|
214
|
+
const stepText = scenario.steps[stepIdx];
|
|
215
|
+
await session.status(`Step ${stepIdx + 1}/${scenario.steps.length}: ${stepText}`);
|
|
216
|
+
log.push(`--- Step ${stepIdx + 1}: ${stepText} ---`);
|
|
217
|
+
|
|
218
|
+
let stepDone = false;
|
|
219
|
+
let actionCount = 0;
|
|
220
|
+
|
|
221
|
+
while (!stepDone && actionCount < maxActionsPerStep) {
|
|
222
|
+
// Capture current state
|
|
223
|
+
const pageState = await capturePageState(session.page);
|
|
224
|
+
|
|
225
|
+
// Ask agent what to do
|
|
226
|
+
const actions = await askAgentForAction(scenario, stepIdx, pageState, log);
|
|
227
|
+
|
|
228
|
+
for (const action of actions) {
|
|
229
|
+
if (action.observation) {
|
|
230
|
+
log.push(` [observe] ${action.observation}`);
|
|
231
|
+
await session.annotate(200, 300, action.observation, 2000);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (action.type === "done") {
|
|
235
|
+
stepDone = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await executeAction(session.page, action);
|
|
240
|
+
log.push(` [action] ${result}`);
|
|
241
|
+
await session.page.waitForTimeout(400); // brief pause for visual recording
|
|
242
|
+
actionCount++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await session.screenshot(
|
|
247
|
+
`scenario-${String(scenarioIndex + 1).padStart(2, "0")}-step-${String(stepIdx + 1).padStart(2, "0")}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
log.push(`=== Scenario complete ===`);
|
|
252
|
+
return { success: true, log };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Fallback: run scenario without ComfyUI (research-only mode) */
|
|
256
|
+
export async function runScenarioResearchOnly(
|
|
257
|
+
session: RecorderSession,
|
|
258
|
+
scenario: TestScenario,
|
|
259
|
+
scenarioIndex: number
|
|
260
|
+
): Promise<{ log: string[] }> {
|
|
261
|
+
const log: string[] = [];
|
|
262
|
+
|
|
263
|
+
await session.step(`Scenario ${scenarioIndex + 1}: ${scenario.name}`);
|
|
264
|
+
await session.plan(scenario.description);
|
|
265
|
+
log.push(`=== Scenario (research-only): ${scenario.name} ===`);
|
|
266
|
+
|
|
267
|
+
// Show preconditions
|
|
268
|
+
for (const pre of scenario.preconditions) {
|
|
269
|
+
await session.status(`Precondition: ${pre}`);
|
|
270
|
+
await session.page.waitForTimeout(800);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Walk through steps as annotations
|
|
274
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
275
|
+
const step = scenario.steps[i];
|
|
276
|
+
await session.status(`Step ${i + 1}/${scenario.steps.length}: ${step}`);
|
|
277
|
+
await session.annotate(
|
|
278
|
+
200 + Math.random() * 300,
|
|
279
|
+
200 + Math.random() * 200,
|
|
280
|
+
`Step ${i + 1}: ${step}`,
|
|
281
|
+
2500
|
|
282
|
+
);
|
|
283
|
+
log.push(` [planned] Step ${i + 1}: ${step}`);
|
|
284
|
+
await session.page.waitForTimeout(1500);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Show expected outcome
|
|
288
|
+
await session.status(`Expected: ${scenario.expectedOutcome}`);
|
|
289
|
+
await session.page.waitForTimeout(1000);
|
|
290
|
+
|
|
291
|
+
await session.screenshot(
|
|
292
|
+
`scenario-${String(scenarioIndex + 1).padStart(2, "0")}-plan`
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
log.push(` [expected] ${scenario.expectedOutcome}`);
|
|
296
|
+
log.push(` [hint] ${scenario.playwrightHint}`);
|
|
297
|
+
return { log };
|
|
298
|
+
}
|