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 CHANGED
@@ -1,15 +1,82 @@
1
- # head
1
+ # comfy-qa
2
2
 
3
- To install dependencies:
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
- bun install
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
- To run:
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 run index.ts
77
+ bun install
13
78
  ```
14
79
 
15
- This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
80
+ ## License
81
+
82
+ MIT
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "comfy-qa",
3
- "version": "1.0.0",
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
+ }