comfy-qa 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -6
- package/package.json +16 -2
- 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
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
|
|
6
|
+
const SKILL_PATH = ".claude/skills/comfy-qa/SKILL.md";
|
|
7
|
+
const REPRODUCE_PATH = ".claude/skills/comfy-qa/REPRODUCE.md";
|
|
8
|
+
const PW_CONFIG_PATH = "playwright.qa.config.ts";
|
|
9
|
+
const QA_SPEC_PATH = "tests/e2e/qa.spec.ts";
|
|
10
|
+
|
|
11
|
+
interface StackInfo {
|
|
12
|
+
framework: "next" | "nuxt" | "vite" | "cra" | "unknown";
|
|
13
|
+
pkgManager: "bun" | "pnpm" | "yarn" | "npm";
|
|
14
|
+
port: number;
|
|
15
|
+
hasPlaywright: boolean;
|
|
16
|
+
hasFirebase: boolean;
|
|
17
|
+
uiLibrary: string | null;
|
|
18
|
+
devScript: string;
|
|
19
|
+
routes: string[];
|
|
20
|
+
testIds: string[];
|
|
21
|
+
depsHash: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Detect the repo stack from package.json and filesystem */
|
|
25
|
+
function detectStack(wsPath: string): StackInfo {
|
|
26
|
+
const pkgPath = path.join(wsPath, "package.json");
|
|
27
|
+
const pkg = fs.existsSync(pkgPath)
|
|
28
|
+
? JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
|
29
|
+
: {};
|
|
30
|
+
|
|
31
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
32
|
+
const depNames = Object.keys(allDeps);
|
|
33
|
+
const scripts = pkg.scripts ?? {};
|
|
34
|
+
|
|
35
|
+
// Framework
|
|
36
|
+
let framework: StackInfo["framework"] = "unknown";
|
|
37
|
+
if (depNames.includes("next")) framework = "next";
|
|
38
|
+
else if (depNames.includes("nuxt")) framework = "nuxt";
|
|
39
|
+
else if (depNames.includes("vite") || depNames.includes("@vitejs/plugin-vue") || depNames.includes("@vitejs/plugin-react")) framework = "vite";
|
|
40
|
+
else if (depNames.includes("react-scripts")) framework = "cra";
|
|
41
|
+
|
|
42
|
+
// Package manager
|
|
43
|
+
let pkgManager: StackInfo["pkgManager"] = "npm";
|
|
44
|
+
if (fs.existsSync(path.join(wsPath, "bun.lock")) || fs.existsSync(path.join(wsPath, "bun.lockb"))) pkgManager = "bun";
|
|
45
|
+
else if (fs.existsSync(path.join(wsPath, "pnpm-lock.yaml"))) pkgManager = "pnpm";
|
|
46
|
+
else if (fs.existsSync(path.join(wsPath, "yarn.lock"))) pkgManager = "yarn";
|
|
47
|
+
|
|
48
|
+
// Port
|
|
49
|
+
let port = 3000;
|
|
50
|
+
if (framework === "vite") port = 5173;
|
|
51
|
+
else if (framework === "nuxt" || framework === "next") port = 3000;
|
|
52
|
+
|
|
53
|
+
// Playwright
|
|
54
|
+
const hasPlaywright = depNames.includes("@playwright/test") || depNames.includes("playwright");
|
|
55
|
+
|
|
56
|
+
// Firebase
|
|
57
|
+
const hasFirebase = depNames.some((d) => d.includes("firebase") || d.includes("vuefire"));
|
|
58
|
+
|
|
59
|
+
// UI library
|
|
60
|
+
let uiLibrary: string | null = null;
|
|
61
|
+
if (depNames.includes("primevue")) uiLibrary = "PrimeVue";
|
|
62
|
+
else if (depNames.includes("flowbite-react")) uiLibrary = "Flowbite React";
|
|
63
|
+
else if (depNames.some((d) => d.startsWith("@mui/"))) uiLibrary = "MUI";
|
|
64
|
+
else if (depNames.includes("@chakra-ui/react")) uiLibrary = "Chakra UI";
|
|
65
|
+
else if (depNames.includes("antd")) uiLibrary = "Ant Design";
|
|
66
|
+
|
|
67
|
+
// Dev script
|
|
68
|
+
const devScript = scripts.dev ? "dev" : scripts.start ? "start" : "dev";
|
|
69
|
+
|
|
70
|
+
// Deps hash for staleness detection
|
|
71
|
+
const depsHash = crypto
|
|
72
|
+
.createHash("md5")
|
|
73
|
+
.update(JSON.stringify(allDeps))
|
|
74
|
+
.digest("hex")
|
|
75
|
+
.slice(0, 8);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
framework,
|
|
79
|
+
pkgManager,
|
|
80
|
+
port,
|
|
81
|
+
hasPlaywright,
|
|
82
|
+
hasFirebase,
|
|
83
|
+
uiLibrary,
|
|
84
|
+
devScript,
|
|
85
|
+
routes: detectRoutes(wsPath, framework),
|
|
86
|
+
testIds: detectTestIds(wsPath),
|
|
87
|
+
depsHash,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Find routes/pages from framework conventions */
|
|
92
|
+
function detectRoutes(wsPath: string, framework: string): string[] {
|
|
93
|
+
const routes: string[] = ["/"];
|
|
94
|
+
const candidates: string[] = [];
|
|
95
|
+
|
|
96
|
+
// Next.js app router
|
|
97
|
+
const appDir = path.join(wsPath, "app");
|
|
98
|
+
if (fs.existsSync(appDir)) {
|
|
99
|
+
candidates.push(...scanDirForPages(appDir, "page.tsx", "page.jsx", "page.ts", "page.js"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Next.js pages router
|
|
103
|
+
const pagesDir = path.join(wsPath, "pages");
|
|
104
|
+
if (fs.existsSync(pagesDir)) {
|
|
105
|
+
candidates.push(...scanDirForPages(pagesDir, "index.tsx", "index.jsx", "index.ts", "index.js"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Nuxt pages
|
|
109
|
+
const nuxtPages = path.join(wsPath, "pages");
|
|
110
|
+
if (framework === "nuxt" && fs.existsSync(nuxtPages)) {
|
|
111
|
+
candidates.push(...scanDirForPages(nuxtPages, "index.vue", ".vue"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Vue Router
|
|
115
|
+
const srcRouter = path.join(wsPath, "src", "router");
|
|
116
|
+
if (fs.existsSync(srcRouter)) {
|
|
117
|
+
// Just note that router exists; routes are in code
|
|
118
|
+
routes.push("/about", "/settings");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const c of candidates) {
|
|
122
|
+
const route = "/" + c.replace(/\/(page|index)\.(tsx?|jsx?|vue)$/, "").replace(/\\/g, "/");
|
|
123
|
+
if (route !== "/" && !routes.includes(route)) routes.push(route);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return routes.slice(0, 10); // cap at 10
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scanDirForPages(dir: string, ...fileNames: string[]): string[] {
|
|
130
|
+
const results: string[] = [];
|
|
131
|
+
try {
|
|
132
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
|
|
133
|
+
if (entry.isFile() && fileNames.some((f) => entry.name.endsWith(f) || entry.name === f)) {
|
|
134
|
+
const rel = path.relative(dir, path.join(entry.parentPath || entry.path, entry.name));
|
|
135
|
+
results.push(rel);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Find existing data-testid attributes */
|
|
143
|
+
function detectTestIds(wsPath: string): string[] {
|
|
144
|
+
const ids: string[] = [];
|
|
145
|
+
const srcDirs = ["src", "app", "pages", "components", "layouts"].map((d) => path.join(wsPath, d));
|
|
146
|
+
|
|
147
|
+
for (const dir of srcDirs) {
|
|
148
|
+
if (!fs.existsSync(dir)) continue;
|
|
149
|
+
try {
|
|
150
|
+
const files = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
if (!f.isFile()) continue;
|
|
153
|
+
if (!/\.(vue|tsx|jsx|svelte)$/.test(f.name)) continue;
|
|
154
|
+
const content = fs.readFileSync(path.join(f.parentPath || f.path, f.name), "utf-8");
|
|
155
|
+
const matches = content.matchAll(/data-testid="([^"]+)"/g);
|
|
156
|
+
for (const m of matches) ids.push(m[1]);
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [...new Set(ids)].slice(0, 30);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Generate all QA skill files */
|
|
165
|
+
function generateSkillFiles(wsPath: string, stack: StackInfo): void {
|
|
166
|
+
// .claude/skills/comfy-qa/SKILL.md
|
|
167
|
+
const skillDir = path.join(wsPath, ".claude", "skills", "comfy-qa");
|
|
168
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
169
|
+
|
|
170
|
+
const skillContent = `---
|
|
171
|
+
name: comfy-qa
|
|
172
|
+
description: 'QA automation for this repo. Runs Playwright E2E tests with video recording.'
|
|
173
|
+
depsHash: "${stack.depsHash}"
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
# QA Skill
|
|
177
|
+
|
|
178
|
+
## Stack
|
|
179
|
+
- Framework: ${stack.framework}
|
|
180
|
+
- Package manager: ${stack.pkgManager}
|
|
181
|
+
- UI library: ${stack.uiLibrary ?? "none detected"}
|
|
182
|
+
- Auth: ${stack.hasFirebase ? "Firebase" : "none detected"}
|
|
183
|
+
|
|
184
|
+
## Prerequisites
|
|
185
|
+
- Node.js 22+
|
|
186
|
+
- \`${stack.pkgManager}\`
|
|
187
|
+
- Playwright browsers: \`npx playwright install chromium\`
|
|
188
|
+
|
|
189
|
+
## Dev Server
|
|
190
|
+
\`\`\`bash
|
|
191
|
+
${stack.pkgManager} run ${stack.devScript} # → http://localhost:${stack.port}
|
|
192
|
+
\`\`\`
|
|
193
|
+
|
|
194
|
+
## Running QA Tests
|
|
195
|
+
\`\`\`bash
|
|
196
|
+
npx playwright test --config playwright.qa.config.ts
|
|
197
|
+
\`\`\`
|
|
198
|
+
|
|
199
|
+
## Key Routes
|
|
200
|
+
${stack.routes.map((r) => `- \`${r}\``).join("\n")}
|
|
201
|
+
|
|
202
|
+
${stack.testIds.length > 0 ? `## Data Test IDs\n${stack.testIds.map((id) => `- \`${id}\``).join("\n")}` : "## Data Test IDs\nNo \`data-testid\` attributes found yet. Add them to key interactive elements."}
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillContent);
|
|
206
|
+
|
|
207
|
+
// REPRODUCE.md
|
|
208
|
+
const reproduceContent = `---
|
|
209
|
+
name: reproduce-issue
|
|
210
|
+
description: 'Reproduce a GitHub issue interactively using Playwright.'
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
# Issue Reproduction
|
|
214
|
+
|
|
215
|
+
## Flow
|
|
216
|
+
1. Read the issue: \`gh issue view <number> --repo owner/repo\`
|
|
217
|
+
2. Start the dev server: \`${stack.pkgManager} run ${stack.devScript}\`
|
|
218
|
+
3. Open Playwright in headed mode for interactive exploration
|
|
219
|
+
4. Follow reproduction steps from the issue
|
|
220
|
+
5. Record video evidence
|
|
221
|
+
6. Generate a minimal reproduction test in \`tests/e2e/qa.spec.ts\`
|
|
222
|
+
|
|
223
|
+
## Prerequisites
|
|
224
|
+
- Dev server running on http://localhost:${stack.port}
|
|
225
|
+
${stack.hasFirebase ? "- Firebase Auth emulator: `firebase emulators:start --only auth`" : ""}
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
fs.writeFileSync(path.join(skillDir, "REPRODUCE.md"), reproduceContent);
|
|
229
|
+
|
|
230
|
+
// playwright.qa.config.ts
|
|
231
|
+
if (!fs.existsSync(path.join(wsPath, PW_CONFIG_PATH))) {
|
|
232
|
+
const pwConfig = `import { defineConfig, devices } from "@playwright/test";
|
|
233
|
+
|
|
234
|
+
export default defineConfig({
|
|
235
|
+
reporter: [["list"], ["html", { open: "never" }]],
|
|
236
|
+
timeout: 60000,
|
|
237
|
+
|
|
238
|
+
use: {
|
|
239
|
+
baseURL: "http://localhost:${stack.port}",
|
|
240
|
+
trace: "on",
|
|
241
|
+
screenshot: "on",
|
|
242
|
+
video: "on",
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
expect: { timeout: 10000 },
|
|
246
|
+
|
|
247
|
+
projects: [
|
|
248
|
+
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
249
|
+
],
|
|
250
|
+
|
|
251
|
+
outputDir: "./tmp/qa-results/",
|
|
252
|
+
testDir: "./tests/e2e",
|
|
253
|
+
});
|
|
254
|
+
`;
|
|
255
|
+
fs.writeFileSync(path.join(wsPath, PW_CONFIG_PATH), pwConfig);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// tests/e2e/qa.spec.ts (only if not exists)
|
|
259
|
+
const specPath = path.join(wsPath, QA_SPEC_PATH);
|
|
260
|
+
if (!fs.existsSync(specPath)) {
|
|
261
|
+
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
|
262
|
+
|
|
263
|
+
const routeTests = stack.routes
|
|
264
|
+
.map(
|
|
265
|
+
(r) => `
|
|
266
|
+
test("loads ${r}", async ({ page }) => {
|
|
267
|
+
await page.goto("${r}");
|
|
268
|
+
await expect(page).not.toHaveTitle(/error/i);
|
|
269
|
+
await page.waitForLoadState("networkidle");
|
|
270
|
+
});`
|
|
271
|
+
)
|
|
272
|
+
.join("\n");
|
|
273
|
+
|
|
274
|
+
const specContent = `import { test, expect } from "@playwright/test";
|
|
275
|
+
|
|
276
|
+
test.describe("QA Smoke Tests", () => {
|
|
277
|
+
test.beforeEach(async ({ page }) => {
|
|
278
|
+
await page.goto("/");
|
|
279
|
+
await page.waitForLoadState("domcontentloaded");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("home page loads", async ({ page }) => {
|
|
283
|
+
await expect(page).not.toHaveTitle(/error/i);
|
|
284
|
+
});
|
|
285
|
+
${routeTests}
|
|
286
|
+
});
|
|
287
|
+
`;
|
|
288
|
+
fs.writeFileSync(specPath, specContent);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// .gitignore — ensure tmp/ is ignored
|
|
292
|
+
const gitignorePath = path.join(wsPath, ".gitignore");
|
|
293
|
+
if (fs.existsSync(gitignorePath)) {
|
|
294
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
295
|
+
if (!content.includes("tmp/")) {
|
|
296
|
+
fs.appendFileSync(gitignorePath, "\n# QA output\ntmp/\n");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Check if QA skill exists and is up-to-date */
|
|
302
|
+
function isSkillCurrent(wsPath: string, stack: StackInfo): boolean {
|
|
303
|
+
const skillPath = path.join(wsPath, SKILL_PATH);
|
|
304
|
+
if (!fs.existsSync(skillPath)) return false;
|
|
305
|
+
|
|
306
|
+
const content = fs.readFileSync(skillPath, "utf-8");
|
|
307
|
+
const hashMatch = content.match(/depsHash:\s*"([^"]+)"/);
|
|
308
|
+
if (!hashMatch) return false;
|
|
309
|
+
|
|
310
|
+
return hashMatch[1] === stack.depsHash;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Ensure the QA skill is set up in the workspace.
|
|
315
|
+
* If missing or stale: checkout comfy-qa branch → generate files → commit.
|
|
316
|
+
*/
|
|
317
|
+
export async function ensureQASkill(wsPath: string): Promise<void> {
|
|
318
|
+
const stack = detectStack(wsPath);
|
|
319
|
+
|
|
320
|
+
if (isSkillCurrent(wsPath, stack)) {
|
|
321
|
+
console.log(` [qa-skill] Up-to-date (hash ${stack.depsHash})`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const skillExists = fs.existsSync(path.join(wsPath, SKILL_PATH));
|
|
326
|
+
console.log(` [qa-skill] ${skillExists ? "Updating" : "Setting up"} QA skill…`);
|
|
327
|
+
console.log(` [qa-skill] Detected: ${stack.framework}, ${stack.pkgManager}, port ${stack.port}`);
|
|
328
|
+
|
|
329
|
+
// Checkout or create comfy-qa branch
|
|
330
|
+
try {
|
|
331
|
+
await $`git -C ${wsPath} checkout comfy-qa`.quiet();
|
|
332
|
+
console.log(` [qa-skill] Switched to existing comfy-qa branch`);
|
|
333
|
+
} catch {
|
|
334
|
+
try {
|
|
335
|
+
await $`git -C ${wsPath} checkout -b comfy-qa`.quiet();
|
|
336
|
+
console.log(` [qa-skill] Created comfy-qa branch`);
|
|
337
|
+
} catch {
|
|
338
|
+
// May fail if already on comfy-qa or detached HEAD — that's fine
|
|
339
|
+
console.log(` [qa-skill] Continuing on current branch`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Generate files
|
|
344
|
+
generateSkillFiles(wsPath, stack);
|
|
345
|
+
console.log(` [qa-skill] Generated: SKILL.md, REPRODUCE.md, playwright.qa.config.ts, qa.spec.ts`);
|
|
346
|
+
|
|
347
|
+
// Install Playwright if not present
|
|
348
|
+
if (!stack.hasPlaywright) {
|
|
349
|
+
console.log(` [qa-skill] Installing @playwright/test…`);
|
|
350
|
+
try {
|
|
351
|
+
if (stack.pkgManager === "bun") {
|
|
352
|
+
await $`bun add -D @playwright/test`.cwd(wsPath).quiet();
|
|
353
|
+
} else if (stack.pkgManager === "pnpm") {
|
|
354
|
+
await $`pnpm add -D @playwright/test`.cwd(wsPath).quiet();
|
|
355
|
+
} else {
|
|
356
|
+
await $`npm install -D @playwright/test`.cwd(wsPath).quiet();
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.log(` [qa-skill] Failed to install Playwright (non-fatal)`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Install Playwright browsers
|
|
364
|
+
try {
|
|
365
|
+
await $`npx playwright install chromium`.cwd(wsPath).quiet();
|
|
366
|
+
} catch {}
|
|
367
|
+
|
|
368
|
+
// Commit
|
|
369
|
+
try {
|
|
370
|
+
await $`git -C ${wsPath} add .claude/ playwright.qa.config.ts tests/e2e/qa.spec.ts .gitignore`.quiet();
|
|
371
|
+
await $`git -C ${wsPath} commit -m "chore: set up comfy-qa E2E testing"`.quiet();
|
|
372
|
+
console.log(` [qa-skill] Committed QA setup`);
|
|
373
|
+
} catch {
|
|
374
|
+
console.log(` [qa-skill] Nothing to commit (files unchanged)`);
|
|
375
|
+
}
|
|
376
|
+
}
|