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,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
+ }