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.
@@ -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
+ }