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,349 @@
1
+ import { $ } from "bun";
2
+ import * as path from "path";
3
+ import * as fs from "fs";
4
+
5
+ export interface ComfyUIInstance {
6
+ url: string;
7
+ pid?: number;
8
+ repoPath?: string;
9
+ /** All spawned processes (backend + frontend etc.) */
10
+ procs: { name: string; pid: number; kill: () => void }[];
11
+ stop: () => Promise<void>;
12
+ }
13
+
14
+ /** Known repo dependency graph: repo → related repos to clone into tmp/ */
15
+ const RELATED_REPOS: Record<string, { owner: string; repo: string; setup?: string }[]> = {
16
+ // Web UIs testable with Playwright — real backend, no mocks
17
+ "ComfyUI_frontend": [{ owner: "Comfy-Org", repo: "ComfyUI", setup: "python" }],
18
+ "registry-web": [{ owner: "Comfy-Org", repo: "comfy-api", setup: "go" }],
19
+ "website": [{ owner: "Comfy-Org", repo: "comfy-api", setup: "go" }],
20
+ };
21
+
22
+ /** Check if a URL is actually serving ComfyUI (not some random app) */
23
+ async function isComfyUI(url: string): Promise<boolean> {
24
+ try {
25
+ const apiResp = await fetch(`${url}/api/system_stats`, {
26
+ signal: AbortSignal.timeout(2000),
27
+ });
28
+ if (apiResp.ok) return true;
29
+ } catch {}
30
+
31
+ try {
32
+ const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
33
+ if (!resp.ok) return false;
34
+ const html = await resp.text();
35
+ return (
36
+ html.includes("comfyui") ||
37
+ html.includes("ComfyUI") ||
38
+ html.includes("comfy-ui") ||
39
+ html.includes("litegraph") ||
40
+ html.includes("comfyApp")
41
+ );
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /** Detect a running ComfyUI instance by checking common ports */
48
+ export async function detectRunningInstance(): Promise<string | null> {
49
+ const candidates = [
50
+ "http://localhost:8188",
51
+ "http://localhost:5173",
52
+ "http://localhost:3000",
53
+ "http://localhost:8000",
54
+ ];
55
+
56
+ for (const url of candidates) {
57
+ if (await isComfyUI(url)) {
58
+ console.log(` [detect] Verified ComfyUI at ${url}`);
59
+ return url;
60
+ }
61
+ }
62
+
63
+ console.log(` [detect] No ComfyUI instance found on common ports`);
64
+ return null;
65
+ }
66
+
67
+ /** Clone a single repo (or reuse existing) */
68
+ async function cloneRepo(
69
+ repoUrl: string,
70
+ destPath: string,
71
+ opts?: { prNumber?: number; branch?: string }
72
+ ): Promise<void> {
73
+ if (fs.existsSync(destPath)) {
74
+ console.log(` [workspace] Reusing: ${destPath}`);
75
+ try { await $`git -C ${destPath} pull --ff-only`.quiet(); } catch {}
76
+ return;
77
+ }
78
+
79
+ console.log(` [workspace] Cloning → ${destPath}`);
80
+ if (opts?.prNumber && !opts?.branch) {
81
+ await $`git clone --depth 50 ${repoUrl} ${destPath}`;
82
+ console.log(` [workspace] Fetching PR #${opts.prNumber} head…`);
83
+ await $`git -C ${destPath} fetch origin pull/${opts.prNumber}/head:qa-pr-${opts.prNumber}`;
84
+ await $`git -C ${destPath} checkout qa-pr-${opts.prNumber}`;
85
+ } else {
86
+ const branch = opts?.branch;
87
+ if (branch) {
88
+ await $`git clone --depth 10 --branch ${branch} ${repoUrl} ${destPath}`;
89
+ } else {
90
+ await $`git clone --depth 10 ${repoUrl} ${destPath}`;
91
+ }
92
+ }
93
+
94
+ const hash = (await $`git -C ${destPath} rev-parse --short HEAD`.text()).trim();
95
+ console.log(` [workspace] Checked out at ${hash}`);
96
+ }
97
+
98
+ /**
99
+ * Clone target repo into .comfy-qa/ws/<name>/
100
+ * Clone related repos into .comfy-qa/ws/<name>/tmp/<related-repo>/
101
+ */
102
+ export async function cloneWorkspace(opts: {
103
+ owner: string;
104
+ repo: string;
105
+ outputBase: string;
106
+ branch?: string;
107
+ prNumber?: number;
108
+ }): Promise<string> {
109
+ const wsBase = path.join(opts.outputBase, "ws");
110
+ fs.mkdirSync(wsBase, { recursive: true });
111
+
112
+ // Target repo
113
+ const refLabel = opts.prNumber && !opts.branch ? `pr-${opts.prNumber}` : (opts.branch ?? "main");
114
+ const dirName = `${opts.repo}-${refLabel}`;
115
+ const wsPath = path.join(wsBase, dirName);
116
+
117
+ await cloneRepo(
118
+ `https://github.com/${opts.owner}/${opts.repo}.git`,
119
+ wsPath,
120
+ { prNumber: opts.prNumber, branch: opts.branch }
121
+ );
122
+
123
+ // Related repos → wsPath/tmp/<repo>/
124
+ const related = RELATED_REPOS[opts.repo];
125
+ if (related) {
126
+ const tmpDir = path.join(wsPath, "tmp");
127
+ fs.mkdirSync(tmpDir, { recursive: true });
128
+
129
+ for (const rel of related) {
130
+ const relPath = path.join(tmpDir, rel.repo);
131
+ console.log(` [related] ${rel.owner}/${rel.repo} → tmp/${rel.repo}/`);
132
+ await cloneRepo(
133
+ `https://github.com/${rel.owner}/${rel.repo}.git`,
134
+ relPath
135
+ );
136
+
137
+ // Setup related repo deps
138
+ if (rel.setup === "python") {
139
+ await setupPythonRepo(relPath);
140
+ } else if (rel.setup === "go") {
141
+ await setupGoRepo(relPath);
142
+ } else {
143
+ await installDeps(relPath);
144
+ }
145
+ }
146
+ }
147
+
148
+ return wsPath;
149
+ }
150
+
151
+ /** Install Python deps (venv + pip) */
152
+ async function setupPythonRepo(repoPath: string): Promise<void> {
153
+ const venvPath = path.join(repoPath, ".venv");
154
+ if (!fs.existsSync(venvPath)) {
155
+ console.log(` [python] Creating venv in ${path.basename(repoPath)}/`);
156
+ try {
157
+ await $`python3 -m venv ${venvPath}`.quiet();
158
+ } catch {
159
+ console.log(` [python] venv creation failed — skipping pip install`);
160
+ return;
161
+ }
162
+ }
163
+
164
+ const pip = path.join(venvPath, "bin", "pip");
165
+ const requirements = path.join(repoPath, "requirements.txt");
166
+ if (fs.existsSync(requirements)) {
167
+ console.log(` [python] Installing requirements…`);
168
+ try {
169
+ await $`${pip} install -r ${requirements}`.cwd(repoPath).quiet();
170
+ } catch (err) {
171
+ console.log(` [python] pip install failed (non-fatal): ${String(err).slice(0, 100)}`);
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Setup Go backend repo (install deps, check for start script) */
177
+ async function setupGoRepo(repoPath: string): Promise<void> {
178
+ console.log(` [go] Setting up ${path.basename(repoPath)}/`);
179
+
180
+ // Check if Go is available
181
+ try {
182
+ await $`go version`.quiet();
183
+ } catch {
184
+ console.log(` [go] Go not installed — backend will need staging URL instead`);
185
+ console.log(` [go] Set API_BASE_URL env var to point to staging server`);
186
+ return;
187
+ }
188
+
189
+ try {
190
+ console.log(` [go] Running go get…`);
191
+ await $`go get`.cwd(repoPath).quiet();
192
+ } catch (err) {
193
+ console.log(` [go] go get failed (non-fatal): ${String(err).slice(0, 100)}`);
194
+ }
195
+ }
196
+
197
+ /** Detect package manager and install deps */
198
+ export async function installDeps(wsPath: string): Promise<void> {
199
+ const pkgJson = path.join(wsPath, "package.json");
200
+ if (!fs.existsSync(pkgJson)) {
201
+ console.log(` [build] No package.json in ${path.basename(wsPath)} — skipping`);
202
+ return;
203
+ }
204
+
205
+ console.log(` [build] Installing deps in ${path.basename(wsPath)}/…`);
206
+ if (fs.existsSync(path.join(wsPath, "bun.lock")) || fs.existsSync(path.join(wsPath, "bun.lockb"))) {
207
+ await $`bun install`.cwd(wsPath).quiet();
208
+ } else if (fs.existsSync(path.join(wsPath, "pnpm-lock.yaml"))) {
209
+ await $`pnpm install --frozen-lockfile`.cwd(wsPath).quiet();
210
+ } else if (fs.existsSync(path.join(wsPath, "yarn.lock"))) {
211
+ await $`yarn install --frozen-lockfile`.cwd(wsPath).quiet();
212
+ } else {
213
+ await $`npm ci`.cwd(wsPath).quiet();
214
+ }
215
+ }
216
+
217
+ /** Start ComfyUI Python backend */
218
+ async function startBackend(comfyPath: string, port = 8188): Promise<{
219
+ url: string;
220
+ proc: { name: string; pid: number; kill: () => void };
221
+ } | null> {
222
+ const mainPy = path.join(comfyPath, "main.py");
223
+ if (!fs.existsSync(mainPy)) return null;
224
+
225
+ const venvPython = path.join(comfyPath, ".venv", "bin", "python3");
226
+ const python = fs.existsSync(venvPython) ? venvPython : "python3";
227
+
228
+ console.log(` [backend] Starting ComfyUI backend on port ${port}…`);
229
+ const proc = Bun.spawn([python, "main.py", "--listen", "0.0.0.0", "--port", String(port), "--cpu"], {
230
+ cwd: comfyPath,
231
+ stdout: "pipe",
232
+ stderr: "pipe",
233
+ });
234
+
235
+ const url = `http://localhost:${port}`;
236
+
237
+ // Wait for backend
238
+ for (let i = 0; i < 30; i++) {
239
+ await Bun.sleep(2000);
240
+ try {
241
+ const r = await fetch(`${url}/api/system_stats`, { signal: AbortSignal.timeout(1500) });
242
+ if (r.ok) {
243
+ console.log(` [backend] Ready at ${url}`);
244
+ return { url, proc: { name: "comfyui-backend", pid: proc.pid, kill: () => proc.kill() } };
245
+ }
246
+ } catch {}
247
+ process.stdout.write(".");
248
+ }
249
+
250
+ console.log(`\n [backend] Did not become ready (may need models/deps)`);
251
+ // Don't kill — it might still start later
252
+ return { url, proc: { name: "comfyui-backend", pid: proc.pid, kill: () => proc.kill() } };
253
+ }
254
+
255
+ /** Start frontend dev server */
256
+ async function startFrontend(wsPath: string, backendUrl?: string): Promise<{
257
+ url: string;
258
+ proc: { name: string; pid: number; kill: () => void };
259
+ }> {
260
+ await installDeps(wsPath);
261
+
262
+ const pkgPath = path.join(wsPath, "package.json");
263
+ let devCmd = "dev";
264
+ if (fs.existsSync(pkgPath)) {
265
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
266
+ const scripts = pkg.scripts ?? {};
267
+ if (scripts.dev) devCmd = "dev";
268
+ else if (scripts.start) devCmd = "start";
269
+ else if (scripts.serve) devCmd = "serve";
270
+ }
271
+
272
+ const port = 5173 + Math.floor(Math.random() * 100);
273
+ const devUrl = `http://localhost:${port}`;
274
+
275
+ console.log(` [frontend] Starting dev server (${devCmd}) on port ${port}…`);
276
+
277
+ const proc = Bun.spawn(["npm", "run", devCmd, "--", "--port", String(port)], {
278
+ cwd: wsPath,
279
+ stdout: "pipe",
280
+ stderr: "pipe",
281
+ env: {
282
+ ...process.env,
283
+ PORT: String(port),
284
+ VITE_PORT: String(port),
285
+ ...(backendUrl ? { COMFY_API_URL: backendUrl } : {}),
286
+ },
287
+ });
288
+
289
+ let ready = false;
290
+ for (let i = 0; i < 30; i++) {
291
+ await Bun.sleep(2000);
292
+ try {
293
+ const r = await fetch(devUrl, { signal: AbortSignal.timeout(1500) });
294
+ if (r.ok) { ready = true; break; }
295
+ } catch {}
296
+ process.stdout.write(".");
297
+ }
298
+
299
+ if (ready) {
300
+ console.log(`\n [frontend] Ready at ${devUrl}`);
301
+ } else {
302
+ console.log(`\n [frontend] May not be ready (will try anyway)`);
303
+ }
304
+
305
+ return {
306
+ url: devUrl,
307
+ proc: { name: "frontend-dev", pid: proc.pid, kill: () => proc.kill() },
308
+ };
309
+ }
310
+
311
+ /** Full bootstrap: clone → install related → start backend → start frontend */
312
+ export async function bootstrapWorkspace(opts: {
313
+ owner: string;
314
+ repo: string;
315
+ outputBase: string;
316
+ branch?: string;
317
+ prNumber?: number;
318
+ }): Promise<ComfyUIInstance> {
319
+ const wsPath = await cloneWorkspace(opts);
320
+ const procs: { name: string; pid: number; kill: () => void }[] = [];
321
+
322
+ // Try to start backend if ComfyUI was cloned as a related repo
323
+ let backendUrl: string | undefined;
324
+ const backendPath = path.join(wsPath, "tmp", "ComfyUI");
325
+ if (fs.existsSync(path.join(backendPath, "main.py"))) {
326
+ const backend = await startBackend(backendPath);
327
+ if (backend) {
328
+ procs.push(backend.proc);
329
+ backendUrl = backend.url;
330
+ }
331
+ }
332
+
333
+ // Start the target repo's frontend
334
+ const frontend = await startFrontend(wsPath, backendUrl);
335
+ procs.push(frontend.proc);
336
+
337
+ return {
338
+ url: backendUrl ?? frontend.url,
339
+ pid: frontend.proc.pid,
340
+ repoPath: wsPath,
341
+ procs,
342
+ stop: async () => {
343
+ for (const p of procs) {
344
+ console.log(` [stop] Killing ${p.name} (pid ${p.pid})`);
345
+ p.kill();
346
+ }
347
+ },
348
+ };
349
+ }
@@ -0,0 +1,87 @@
1
+ import { $ } from "bun";
2
+
3
+ export interface PRInfo {
4
+ number: number;
5
+ title: string;
6
+ body: string;
7
+ state: string;
8
+ headRefName: string;
9
+ baseRefName: string;
10
+ author: string;
11
+ labels: string[];
12
+ url: string;
13
+ files: { path: string; additions: number; deletions: number }[];
14
+ comments: { author: string; body: string; createdAt: string }[];
15
+ }
16
+
17
+ export interface IssueInfo {
18
+ number: number;
19
+ title: string;
20
+ body: string;
21
+ state: string;
22
+ author: string;
23
+ labels: string[];
24
+ url: string;
25
+ comments: { author: string; body: string; createdAt: string }[];
26
+ }
27
+
28
+ /** Parse "owner/repo#number" or "owner/repo" + number */
29
+ export function parseRef(ref: string): { owner: string; repo: string; number?: number } {
30
+ const match = ref.match(/^([^/]+)\/([^#]+)(?:#(\d+))?$/);
31
+ if (!match) throw new Error(`Invalid ref: ${ref}. Expected owner/repo#number`);
32
+ return {
33
+ owner: match[1],
34
+ repo: match[2],
35
+ number: match[3] ? parseInt(match[3]) : undefined,
36
+ };
37
+ }
38
+
39
+ export async function fetchPR(owner: string, repo: string, number: number): Promise<PRInfo> {
40
+ const json = await $`gh pr view ${number} --repo ${owner}/${repo} --json number,title,body,state,headRefName,baseRefName,author,labels,url,files,comments`.text();
41
+ const raw = JSON.parse(json);
42
+ return {
43
+ number: raw.number,
44
+ title: raw.title,
45
+ body: raw.body || "",
46
+ state: raw.state,
47
+ headRefName: raw.headRefName,
48
+ baseRefName: raw.baseRefName,
49
+ author: raw.author?.login || "unknown",
50
+ labels: (raw.labels || []).map((l: any) => l.name),
51
+ url: raw.url,
52
+ files: (raw.files || []).map((f: any) => ({
53
+ path: f.path,
54
+ additions: f.additions,
55
+ deletions: f.deletions,
56
+ })),
57
+ comments: (raw.comments || []).map((c: any) => ({
58
+ author: c.author?.login || "unknown",
59
+ body: c.body || "",
60
+ createdAt: c.createdAt,
61
+ })),
62
+ };
63
+ }
64
+
65
+ export async function fetchIssue(owner: string, repo: string, number: number): Promise<IssueInfo> {
66
+ const json = await $`gh issue view ${number} --repo ${owner}/${repo} --json number,title,body,state,author,labels,url,comments`.text();
67
+ const raw = JSON.parse(json);
68
+ return {
69
+ number: raw.number,
70
+ title: raw.title,
71
+ body: raw.body || "",
72
+ state: raw.state,
73
+ author: raw.author?.login || "unknown",
74
+ labels: (raw.labels || []).map((l: any) => l.name),
75
+ url: raw.url,
76
+ comments: (raw.comments || []).map((c: any) => ({
77
+ author: c.author?.login || "unknown",
78
+ body: c.body || "",
79
+ createdAt: c.createdAt,
80
+ })),
81
+ };
82
+ }
83
+
84
+ export async function fetchRecentIssues(owner: string, repo: string, limit = 20): Promise<IssueInfo[]> {
85
+ const json = await $`gh issue list --repo ${owner}/${repo} --limit ${limit} --state open --json number,title,body,state,author,labels,url`.text();
86
+ return JSON.parse(json);
87
+ }
@@ -0,0 +1,11 @@
1
+ /** Parse a GitHub PR/issue URL into { type, ref } */
2
+ export function parseGitHubUrl(url: string): { type: "pr" | "issue"; ref: string } | null {
3
+ const m = url.match(
4
+ /github\.com\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)/
5
+ );
6
+ if (!m) return null;
7
+ return {
8
+ type: m[3] === "pull" ? "pr" : "issue",
9
+ ref: `${m[1]}/${m[2]}#${m[4]}`,
10
+ };
11
+ }