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,268 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { fetchPR, fetchIssue, parseRef } from "../utils/github";
|
|
4
|
+
import { detectRunningInstance, bootstrapWorkspace, type ComfyUIInstance } from "../utils/comfyui";
|
|
5
|
+
import { researchPR, researchIssue } from "./research";
|
|
6
|
+
import { startRecorder, navigateWithHUD } from "../browser/recorder";
|
|
7
|
+
import { runScenarioWithAgent, runScenarioResearchOnly } from "./browser-agent";
|
|
8
|
+
import { saveReport } from "../report/generate";
|
|
9
|
+
import { generateE2ETest } from "../report/e2e-test";
|
|
10
|
+
import { ensureQASkill } from "../utils/qa-skill";
|
|
11
|
+
import { cloneWorkspace } from "../utils/comfyui";
|
|
12
|
+
import { generateNarration, type NarrationSegment } from "../recorder/narration";
|
|
13
|
+
import { postMix } from "../recorder/post-mix";
|
|
14
|
+
|
|
15
|
+
export interface QAOptions {
|
|
16
|
+
ref: string; // "Comfy-Org/ComfyUI_frontend#9430" or issue ref
|
|
17
|
+
type: "pr" | "issue" | "auto";
|
|
18
|
+
record: boolean;
|
|
19
|
+
outputBase: string;
|
|
20
|
+
comfyUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runQA(opts: QAOptions): Promise<void> {
|
|
24
|
+
const { ref, record, outputBase } = opts;
|
|
25
|
+
|
|
26
|
+
// 1. Parse ref
|
|
27
|
+
const parsed = parseRef(ref);
|
|
28
|
+
if (!parsed.number) throw new Error(`Ref must include a number: ${ref}`);
|
|
29
|
+
|
|
30
|
+
// Ensure .comfy-qa/.gitignore exists to self-ignore all output
|
|
31
|
+
fs.mkdirSync(outputBase, { recursive: true });
|
|
32
|
+
const gitignorePath = path.join(outputBase, ".gitignore");
|
|
33
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
34
|
+
fs.writeFileSync(gitignorePath, "*\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const slug = `${parsed.owner}-${parsed.repo}-${opts.type === "issue" ? "issue" : "pr"}-${parsed.number}`;
|
|
38
|
+
const outputDir = path.join(outputBase, slug);
|
|
39
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
console.log(`\n${"═".repeat(60)}`);
|
|
42
|
+
console.log(` Comfy-QA Agent`);
|
|
43
|
+
console.log(` Target: ${ref}`);
|
|
44
|
+
console.log(` Output: ${outputDir}`);
|
|
45
|
+
console.log(`${"═".repeat(60)}\n`);
|
|
46
|
+
|
|
47
|
+
// 2. Fetch target info
|
|
48
|
+
let target: Awaited<ReturnType<typeof fetchPR>> | Awaited<ReturnType<typeof fetchIssue>>;
|
|
49
|
+
let targetType: "pr" | "issue";
|
|
50
|
+
|
|
51
|
+
if (opts.type === "pr" || opts.type === "auto") {
|
|
52
|
+
try {
|
|
53
|
+
console.log(`[1/5] Fetching PR data…`);
|
|
54
|
+
target = await fetchPR(parsed.owner, parsed.repo, parsed.number);
|
|
55
|
+
targetType = "pr";
|
|
56
|
+
} catch {
|
|
57
|
+
if (opts.type === "pr") throw new Error(`Could not fetch PR ${ref}`);
|
|
58
|
+
console.log(` PR not found, trying as issue…`);
|
|
59
|
+
target = await fetchIssue(parsed.owner, parsed.repo, parsed.number);
|
|
60
|
+
targetType = "issue";
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
console.log(`[1/5] Fetching issue data…`);
|
|
64
|
+
target = await fetchIssue(parsed.owner, parsed.repo, parsed.number);
|
|
65
|
+
targetType = "issue";
|
|
66
|
+
}
|
|
67
|
+
console.log(` ✓ ${target.title}`);
|
|
68
|
+
|
|
69
|
+
// 3. Research phase
|
|
70
|
+
console.log(`\n[2/5] Research phase — Claude analyzing ${targetType}…`);
|
|
71
|
+
const research = targetType === "pr"
|
|
72
|
+
? await researchPR(target as Awaited<ReturnType<typeof fetchPR>>)
|
|
73
|
+
: await researchIssue(target as Awaited<ReturnType<typeof fetchIssue>>);
|
|
74
|
+
|
|
75
|
+
console.log(` ✓ Severity: ${research.severity} | Area: ${research.affectedArea}`);
|
|
76
|
+
console.log(` ✓ ${research.qaChecklist.length} checklist items | ${research.testScenarios.length} test scenarios`);
|
|
77
|
+
|
|
78
|
+
// 4. Browser recording phase
|
|
79
|
+
let screenshots: string[] = [];
|
|
80
|
+
let videoPath: string | undefined;
|
|
81
|
+
const allLogs: string[] = [];
|
|
82
|
+
|
|
83
|
+
if (record) {
|
|
84
|
+
console.log(`\n[3/5] Recording phase — Playwright + HUD…`);
|
|
85
|
+
|
|
86
|
+
// Resolve ComfyUI URL: explicit flag → auto-detect → auto-bootstrap
|
|
87
|
+
let comfyUrl = opts.comfyUrl || await detectRunningInstance();
|
|
88
|
+
let bootstrappedInstance: ComfyUIInstance | null = null;
|
|
89
|
+
|
|
90
|
+
if (!comfyUrl) {
|
|
91
|
+
// Auto clone + build the target repo
|
|
92
|
+
console.log(` [bootstrap] No running instance — cloning & building target repo…`);
|
|
93
|
+
const prBranch = targetType === "pr"
|
|
94
|
+
? (target as Awaited<ReturnType<typeof fetchPR>>).headRefName
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
// Clone workspace first so we can set up QA skill before bootstrapping
|
|
98
|
+
const wsPath = await cloneWorkspace({
|
|
99
|
+
owner: parsed.owner,
|
|
100
|
+
repo: parsed.repo,
|
|
101
|
+
outputBase,
|
|
102
|
+
branch: prBranch,
|
|
103
|
+
prNumber: targetType === "pr" ? parsed.number : undefined,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Ensure QA skill exists (creates comfy-qa branch + generates files if missing)
|
|
107
|
+
await ensureQASkill(wsPath);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
bootstrappedInstance = await bootstrapWorkspace({
|
|
111
|
+
owner: parsed.owner,
|
|
112
|
+
repo: parsed.repo,
|
|
113
|
+
outputBase,
|
|
114
|
+
branch: prBranch,
|
|
115
|
+
prNumber: targetType === "pr" ? parsed.number : undefined,
|
|
116
|
+
});
|
|
117
|
+
comfyUrl = bootstrappedInstance.url;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.log(` [bootstrap] Failed: ${err}`);
|
|
120
|
+
console.log(` [bootstrap] Falling back to research-only mode`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pre-generate narration BEFORE recording (so durations are known)
|
|
125
|
+
const narrationSegments: NarrationSegment[] = [
|
|
126
|
+
{ id: "intro", text: `Welcome to comfy QA. Let's review ${targetType} number ${parsed.number}: ${target.title.slice(0, 100)}` },
|
|
127
|
+
{ id: "github", text: `First, let's look at the GitHub ${targetType} page for context.` },
|
|
128
|
+
{ id: "analysis", text: `Severity ${research.severity}. Affected area: ${research.affectedArea}.` },
|
|
129
|
+
...research.testScenarios.flatMap((s, i): NarrationSegment[] => [
|
|
130
|
+
{ id: `scenario-${i + 1}-intro`, text: `Scenario ${i + 1}: ${s.name}. ${s.description}` },
|
|
131
|
+
...s.steps.slice(0, 5).map((step, j) => ({
|
|
132
|
+
id: `scenario-${i + 1}-step-${j + 1}`,
|
|
133
|
+
text: `Step ${j + 1}: ${step.slice(0, 150)}`,
|
|
134
|
+
})),
|
|
135
|
+
]),
|
|
136
|
+
{ id: "outro", text: `QA session complete. Report and video evidence saved.` },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const narration = await generateNarration(narrationSegments, outputDir);
|
|
140
|
+
|
|
141
|
+
const session = await startRecorder(outputDir, `qa-${parsed.number}`);
|
|
142
|
+
if (narration) session.attachNarration(narration.durations);
|
|
143
|
+
const ffmpegStartMs = Date.now();
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// Screenshot the GitHub page for evidence
|
|
147
|
+
if (narration) await session.narrate("intro", `Opening ${target.url}`);
|
|
148
|
+
else await session.step(`Opening ${target.url}`);
|
|
149
|
+
await navigateWithHUD(session, target.url, `QA: ${targetType.toUpperCase()} #${parsed.number}`);
|
|
150
|
+
await session.plan(`Analyzing: ${target.title}`);
|
|
151
|
+
if (narration) await session.narrate("github", "Inspecting GitHub page");
|
|
152
|
+
else await session.page.waitForTimeout(2000);
|
|
153
|
+
if (narration) await session.narrate("analysis", `${research.severity} severity`);
|
|
154
|
+
await session.screenshot("01-github-page");
|
|
155
|
+
|
|
156
|
+
if (comfyUrl) {
|
|
157
|
+
// ── ComfyUI available: AI agent drives the browser ──
|
|
158
|
+
console.log(` [mode] Agent-driven QA against ${comfyUrl}`);
|
|
159
|
+
await session.step(`Navigating to ComfyUI at ${comfyUrl}`);
|
|
160
|
+
await navigateWithHUD(session, comfyUrl, `ComfyUI QA — ${targetType.toUpperCase()} #${parsed.number}`);
|
|
161
|
+
await session.page.waitForTimeout(2000);
|
|
162
|
+
await session.screenshot("02-comfyui-loaded");
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < research.testScenarios.length; i++) {
|
|
165
|
+
const scenario = research.testScenarios[i];
|
|
166
|
+
console.log(` [scenario ${i + 1}/${research.testScenarios.length}] ${scenario.name}`);
|
|
167
|
+
|
|
168
|
+
const result = await runScenarioWithAgent(session, scenario, i);
|
|
169
|
+
allLogs.push(...result.log);
|
|
170
|
+
|
|
171
|
+
if (i < research.testScenarios.length - 1) {
|
|
172
|
+
await session.page.goto(comfyUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
173
|
+
await session.page.waitForTimeout(1000);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// ── No ComfyUI: research-only recording with GitHub evidence ──
|
|
178
|
+
console.log(` [mode] Research-only (no ComfyUI instance available)`);
|
|
179
|
+
|
|
180
|
+
await session.step("Scrolling through issue details");
|
|
181
|
+
for (let scroll = 0; scroll < 3; scroll++) {
|
|
182
|
+
await session.page.mouse.wheel(0, 400);
|
|
183
|
+
await session.page.waitForTimeout(1000);
|
|
184
|
+
}
|
|
185
|
+
await session.screenshot("02-github-details");
|
|
186
|
+
|
|
187
|
+
for (let i = 0; i < research.testScenarios.length; i++) {
|
|
188
|
+
const scenario = research.testScenarios[i];
|
|
189
|
+
console.log(` [scenario ${i + 1}/${research.testScenarios.length}] ${scenario.name} (planned)`);
|
|
190
|
+
|
|
191
|
+
if (narration) {
|
|
192
|
+
await session.narrate(`scenario-${i + 1}-intro`, `Scenario ${i + 1}: ${scenario.name}`);
|
|
193
|
+
for (let j = 0; j < Math.min(scenario.steps.length, 5); j++) {
|
|
194
|
+
await session.narrate(`scenario-${i + 1}-step-${j + 1}`, `Step ${j + 1}: ${scenario.steps[j].slice(0, 80)}`);
|
|
195
|
+
}
|
|
196
|
+
await session.screenshot(`scenario-${String(i + 1).padStart(2, "0")}-plan`);
|
|
197
|
+
} else {
|
|
198
|
+
const result = await runScenarioResearchOnly(session, scenario, i);
|
|
199
|
+
allLogs.push(...result.log);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (narration) await session.narrate("outro", "QA Session complete");
|
|
205
|
+
else await session.step("QA Session complete");
|
|
206
|
+
await session.status(comfyUrl ? "Agent-driven QA finished" : "Research-only QA finished");
|
|
207
|
+
await session.page.waitForTimeout(1500);
|
|
208
|
+
await session.screenshot("99-final");
|
|
209
|
+
screenshots = session.screenshots;
|
|
210
|
+
} finally {
|
|
211
|
+
const demoStartMs = session.getDemoStartMs();
|
|
212
|
+
await session.stop();
|
|
213
|
+
if (bootstrappedInstance) {
|
|
214
|
+
await bootstrappedInstance.stop();
|
|
215
|
+
}
|
|
216
|
+
const webm = path.join(outputDir, `qa-${parsed.number}.webm`);
|
|
217
|
+
if (fs.existsSync(webm)) videoPath = webm;
|
|
218
|
+
|
|
219
|
+
// Post-mix narration onto the recorded video
|
|
220
|
+
if (narration && videoPath) {
|
|
221
|
+
try {
|
|
222
|
+
const offsetMs = Math.max(0, demoStartMs - ffmpegStartMs);
|
|
223
|
+
const finalPath = path.join(outputDir, `qa-${parsed.number}-narrated.mp4`);
|
|
224
|
+
await postMix(videoPath, narration.trackPath, narration.metaPath, offsetMs, finalPath);
|
|
225
|
+
videoPath = finalPath;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.log(` [post-mix] Failed: ${String(err).slice(0, 200)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Save agent logs
|
|
233
|
+
if (allLogs.length > 0) {
|
|
234
|
+
const logPath = path.join(outputDir, "agent-log.txt");
|
|
235
|
+
fs.writeFileSync(logPath, allLogs.join("\n"));
|
|
236
|
+
console.log(` [log] ${logPath}`);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
console.log(`\n[3/5] Skipping recording (use --record to enable)`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 5. Generate E2E test file
|
|
243
|
+
const e2eTestCode = generateE2ETest(target, targetType, research);
|
|
244
|
+
const e2eTestPath = path.join(outputDir, `${targetType}-${parsed.number}.e2e.ts`);
|
|
245
|
+
fs.writeFileSync(e2eTestPath, e2eTestCode);
|
|
246
|
+
console.log(` [e2e] ${e2eTestPath}`);
|
|
247
|
+
|
|
248
|
+
// 6. Report generation
|
|
249
|
+
console.log(`\n[5/5] Generating report…`);
|
|
250
|
+
const result = saveReport({
|
|
251
|
+
target,
|
|
252
|
+
targetType,
|
|
253
|
+
research,
|
|
254
|
+
outputDir,
|
|
255
|
+
screenshots,
|
|
256
|
+
videoPath,
|
|
257
|
+
runAt: new Date(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Summary
|
|
261
|
+
console.log(`\n${"═".repeat(60)}`);
|
|
262
|
+
console.log(` ✅ QA Complete`);
|
|
263
|
+
console.log(` Report: ${result.reportPath}`);
|
|
264
|
+
console.log(` QA Sheet: ${result.qaSheetPath}`);
|
|
265
|
+
if (videoPath) console.log(` Video: ${videoPath}`);
|
|
266
|
+
if (screenshots.length > 0) console.log(` Screenshots: ${screenshots.length} files`);
|
|
267
|
+
console.log(`${"═".repeat(60)}\n`);
|
|
268
|
+
}
|