@nhonh/qabot 0.5.1 → 0.6.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/bin/qabot.js CHANGED
@@ -8,6 +8,7 @@ import { registerListCommand } from "../src/cli/commands/list.js";
8
8
  import { registerGenerateCommand } from "../src/cli/commands/generate.js";
9
9
  import { registerReportCommand } from "../src/cli/commands/report.js";
10
10
  import { registerAuthCommand } from "../src/cli/commands/auth.js";
11
+ import { registerTestCommand } from "../src/cli/commands/test.js";
11
12
 
12
13
  const program = new Command();
13
14
 
@@ -18,6 +19,7 @@ program
18
19
 
19
20
  registerInitCommand(program);
20
21
  registerRunCommand(program);
22
+ registerTestCommand(program);
21
23
  registerListCommand(program);
22
24
  registerGenerateCommand(program);
23
25
  registerReportCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nhonh/qabot",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "AI-powered universal QA automation tool. Import any project, AI analyzes and runs tests across all layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,466 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import path from "node:path";
4
+ import { writeFile, readFile } from "node:fs/promises";
5
+ import { execSync } from "node:child_process";
6
+ import { logger } from "../../core/logger.js";
7
+ import { loadConfig } from "../../core/config.js";
8
+ import { analyzeProject } from "../../analyzers/project-analyzer.js";
9
+ import { AIEngine } from "../../ai/ai-engine.js";
10
+ import { UseCaseParser } from "../../ai/usecase-parser.js";
11
+ import {
12
+ ensurePlaywright,
13
+ ensureE2EStructure,
14
+ writePlaywrightConfig,
15
+ writeAuthHelper,
16
+ } from "../../e2e/playwright-setup.js";
17
+ import { generateE2ESpec, fixE2ESpec } from "../../e2e/e2e-generator.js";
18
+ import {
19
+ findFiles,
20
+ safeReadFile,
21
+ ensureDir,
22
+ fileExists,
23
+ } from "../../utils/file-utils.js";
24
+ import { ReportGenerator } from "../../reporter/report-generator.js";
25
+
26
+ const MAX_FIX_ATTEMPTS = 3;
27
+
28
+ export function registerTestCommand(program) {
29
+ program
30
+ .command("test [feature]")
31
+ .description("Run E2E automation tests (Playwright)")
32
+ .option("-e, --env <environment>", "Target environment", "default")
33
+ .option("--headed", "Run browser in headed mode (visible)")
34
+ .option("--no-fix", "Skip auto-fix on failure")
35
+ .option("--skip-gen", "Skip test generation, run existing specs only")
36
+ .option("--use-cases <dir>", "Directory containing use case documents")
37
+ .option("--model <model>", "AI model to use")
38
+ .option("-d, --dir <path>", "Project directory", process.cwd())
39
+ .action(runTest);
40
+ }
41
+
42
+ async function runTest(feature, options) {
43
+ const projectDir = options.dir;
44
+ const { config, isEmpty } = await loadConfig(projectDir);
45
+
46
+ if (isEmpty) {
47
+ logger.warn("No qabot.config.json found. Run `qabot init` first.");
48
+ return;
49
+ }
50
+
51
+ const profile = await analyzeProject(projectDir);
52
+ const envConfig =
53
+ config.environments?.[options.env] || config.environments?.default;
54
+ const baseUrl = envConfig?.url || "http://localhost:3000";
55
+
56
+ logger.header("QABot \u2014 E2E Automation Test");
57
+ logger.blank();
58
+ logger.info(`Feature: ${chalk.bold(feature || "all")}`);
59
+ logger.info(`Target: ${chalk.bold.cyan(baseUrl)}`);
60
+ logger.info(`Environment: ${chalk.bold(options.env)}`);
61
+ logger.info(
62
+ `Browser: ${options.headed ? "Headed (visible)" : "Headless"}`,
63
+ );
64
+ logger.blank();
65
+ console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
66
+
67
+ const spinner = ora("Setting up Playwright...").start();
68
+ try {
69
+ await ensurePlaywright(projectDir);
70
+ await ensureE2EStructure(projectDir);
71
+ await writePlaywrightConfig(projectDir, config);
72
+ await writeAuthHelper(projectDir, config);
73
+ spinner.succeed("Playwright ready");
74
+ } catch (err) {
75
+ spinner.fail(`Playwright setup failed: ${err.message}`);
76
+ return;
77
+ }
78
+
79
+ const featureConfig = feature ? config.features?.[feature] : null;
80
+ const specDir = path.join(projectDir, "e2e", "tests");
81
+ let specFile;
82
+
83
+ if (options.skipGen) {
84
+ const existing = await findFiles(specDir, "*.spec.js");
85
+ if (existing.length === 0) {
86
+ logger.error(
87
+ "No existing E2E specs found. Run without --skip-gen to generate.",
88
+ );
89
+ return;
90
+ }
91
+ specFile = feature
92
+ ? existing.find((f) => f.toLowerCase().includes(feature.toLowerCase()))
93
+ : null;
94
+ logger.info(
95
+ `Running ${specFile ? path.basename(specFile) : "all"} existing specs`,
96
+ );
97
+ } else {
98
+ if (!feature) {
99
+ logger.error(
100
+ "Feature name required for test generation. Usage: qabot test <feature>",
101
+ );
102
+ return;
103
+ }
104
+
105
+ const aiConfig = { ...config.ai };
106
+ if (options.model) aiConfig.model = options.model;
107
+ const ai = new AIEngine(aiConfig);
108
+
109
+ if (!ai.isAvailable()) {
110
+ logger.error("AI not configured. Run `qabot auth` first.");
111
+ return;
112
+ }
113
+
114
+ const spinner2 = ora(
115
+ "AI is analyzing feature and generating E2E tests...",
116
+ ).start();
117
+
118
+ let sourceCode = "";
119
+ if (featureConfig?.src) {
120
+ const sourceFiles = await findFiles(
121
+ projectDir,
122
+ `${featureConfig.src}/**/*.{js,jsx,ts,tsx}`,
123
+ );
124
+ const filtered = sourceFiles.filter(
125
+ (f) => !f.includes("/tests/") && !f.includes(".test."),
126
+ );
127
+ sourceCode = (
128
+ await Promise.all(filtered.slice(0, 8).map((f) => safeReadFile(f)))
129
+ )
130
+ .filter(Boolean)
131
+ .join("\n\n---\n\n");
132
+ }
133
+
134
+ let useCases = [];
135
+ const useCaseDir = options.useCases || config.useCases?.dir;
136
+ if (useCaseDir) {
137
+ const parser = new UseCaseParser();
138
+ const ucFiles = await findFiles(
139
+ projectDir,
140
+ `${useCaseDir}/**/*.{md,feature,txt}`,
141
+ );
142
+ for (const f of ucFiles) {
143
+ try {
144
+ useCases.push(...(await parser.parse(f)));
145
+ } catch {
146
+ /* skip */
147
+ }
148
+ }
149
+ }
150
+
151
+ const route = featureConfig?.route || guessRoute(feature);
152
+
153
+ try {
154
+ const spec = await generateE2ESpec(ai, feature, {
155
+ baseUrl,
156
+ sourceCode,
157
+ route,
158
+ authProvider: config.auth?.provider || "none",
159
+ useCases,
160
+ });
161
+
162
+ specFile = path.join(specDir, `${feature}.spec.js`);
163
+ await writeFile(specFile, spec, "utf-8");
164
+ spinner2.succeed(
165
+ `E2E spec generated: ${chalk.underline(path.relative(projectDir, specFile))}`,
166
+ );
167
+ } catch (err) {
168
+ spinner2.fail(`E2E generation failed: ${err.message}`);
169
+ return;
170
+ }
171
+
172
+ if (options.fix !== false) {
173
+ const aiForFix = ai;
174
+
175
+ for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
176
+ logger.blank();
177
+ logger.step(
178
+ attempt,
179
+ MAX_FIX_ATTEMPTS,
180
+ `Running E2E tests (attempt ${attempt})...`,
181
+ );
182
+
183
+ const {
184
+ exitCode,
185
+ error: testError,
186
+ stdout,
187
+ } = runPlaywright(projectDir, specFile, options);
188
+
189
+ if (exitCode === 0) {
190
+ printPlaywrightSummary(stdout);
191
+ logger.blank();
192
+ logger.success(`E2E tests passed on attempt ${attempt}!`);
193
+ await generateE2EReport(
194
+ projectDir,
195
+ config,
196
+ feature,
197
+ options.env,
198
+ baseUrl,
199
+ );
200
+ return;
201
+ }
202
+
203
+ if (attempt >= MAX_FIX_ATTEMPTS) {
204
+ printPlaywrightSummary(stdout || testError);
205
+ logger.blank();
206
+ logger.warn(
207
+ `E2E tests still failing after ${MAX_FIX_ATTEMPTS} attempts.`,
208
+ );
209
+ logger.dim(` Review: cat ${path.relative(projectDir, specFile)}`);
210
+ logger.dim(` Debug: qabot test ${feature} --headed`);
211
+ await generateE2EReport(
212
+ projectDir,
213
+ config,
214
+ feature,
215
+ options.env,
216
+ baseUrl,
217
+ );
218
+ return;
219
+ }
220
+
221
+ logger.dim(` ${extractFirstError(testError || stdout)}`);
222
+ logger.step(attempt, MAX_FIX_ATTEMPTS, "AI is fixing E2E test...");
223
+
224
+ try {
225
+ const currentCode = await readFile(specFile, "utf-8");
226
+ const fixedCode = await fixE2ESpec(
227
+ aiForFix,
228
+ currentCode,
229
+ testError || stdout,
230
+ {
231
+ baseUrl,
232
+ authProvider: config.auth?.provider,
233
+ },
234
+ );
235
+ await writeFile(specFile, fixedCode, "utf-8");
236
+ logger.dim(" Fix applied. Re-running...");
237
+ } catch (err) {
238
+ logger.warn(` AI fix failed: ${err.message}`);
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ if (!specFile && !options.skipGen) return;
246
+
247
+ logger.blank();
248
+ logger.info("Running E2E tests...");
249
+
250
+ const {
251
+ exitCode,
252
+ stdout,
253
+ error: testError,
254
+ } = runPlaywright(projectDir, specFile, options);
255
+ printPlaywrightSummary(stdout || testError);
256
+ await generateE2EReport(
257
+ projectDir,
258
+ config,
259
+ feature || "all",
260
+ options.env,
261
+ baseUrl,
262
+ );
263
+
264
+ if (exitCode !== 0) process.exit(1);
265
+ }
266
+
267
+ function runPlaywright(projectDir, specFile, options) {
268
+ const configPath = path.join(projectDir, "e2e", "playwright.config.js");
269
+ const parts = ["npx", "playwright", "test"];
270
+ if (specFile)
271
+ parts.push(path.relative(path.join(projectDir, "e2e"), specFile));
272
+ parts.push(`--config="${configPath}"`);
273
+ parts.push("--project=chromium");
274
+ if (options.headed) parts.push("--headed");
275
+
276
+ const env = {
277
+ ...process.env,
278
+ E2E_ENV: options.env || "default",
279
+ FORCE_COLOR: "0",
280
+ };
281
+
282
+ try {
283
+ const stdout = execSync(parts.join(" "), {
284
+ cwd: projectDir,
285
+ stdio: "pipe",
286
+ timeout: 120000,
287
+ env,
288
+ }).toString();
289
+ return { exitCode: 0, stdout, error: "" };
290
+ } catch (err) {
291
+ return {
292
+ exitCode: err.status || 1,
293
+ stdout: err.stdout?.toString() || "",
294
+ error: err.stderr?.toString() || err.stdout?.toString() || "",
295
+ };
296
+ }
297
+ }
298
+
299
+ function printPlaywrightSummary(output) {
300
+ if (!output) return;
301
+ const lines = output.split("\n");
302
+ for (const line of lines) {
303
+ const trimmed = line.trim();
304
+ if (!trimmed) continue;
305
+ if (trimmed.includes("\u2713") || trimmed.includes("passed")) {
306
+ logger.success(trimmed);
307
+ } else if (
308
+ trimmed.includes("\u2717") ||
309
+ trimmed.includes("failed") ||
310
+ trimmed.includes("Error")
311
+ ) {
312
+ logger.error(trimmed);
313
+ } else if (
314
+ trimmed.includes("─") ||
315
+ trimmed.includes("Running") ||
316
+ trimmed.includes("test")
317
+ ) {
318
+ logger.dim(trimmed);
319
+ }
320
+ }
321
+ }
322
+
323
+ function extractFirstError(output) {
324
+ if (!output) return "Unknown error";
325
+ const lines = output.split("\n").filter((l) => l.trim());
326
+ const errorLine = lines.find(
327
+ (l) =>
328
+ l.includes("Error") ||
329
+ l.includes("expect") ||
330
+ l.includes("Timeout") ||
331
+ l.includes("locator"),
332
+ );
333
+ return (
334
+ errorLine?.trim().slice(0, 150) ||
335
+ lines[0]?.trim().slice(0, 150) ||
336
+ "Test failed"
337
+ );
338
+ }
339
+
340
+ function guessRoute(featureName) {
341
+ const routes = {
342
+ home: "/",
343
+ lobby: "/lobby",
344
+ auth: "/signin",
345
+ account: "/account",
346
+ redemption: "/redemption",
347
+ "refer-a-friend": "/refer-a-friend",
348
+ faq: "/faq",
349
+ promotions: "/promotions",
350
+ "game-details": "/game-details",
351
+ "game-zone": "/game-zone",
352
+ "privacy-policy": "/privacy-policy",
353
+ "terms-of-service": "/terms-of-service",
354
+ };
355
+ return routes[featureName] || `/${featureName}`;
356
+ }
357
+
358
+ async function generateE2EReport(projectDir, config, feature, env, baseUrl) {
359
+ try {
360
+ const screenshotsDir = path.join(projectDir, "e2e", "screenshots");
361
+ const screenshots = await findFiles(screenshotsDir, "*.png").catch(
362
+ () => [],
363
+ );
364
+
365
+ const reporter = new ReportGenerator(config);
366
+ const results = {
367
+ summary: {
368
+ totalTests: 0,
369
+ totalPassed: 0,
370
+ totalFailed: 0,
371
+ totalSkipped: 0,
372
+ overallPassRate: 0,
373
+ byLayer: {},
374
+ },
375
+ results: [],
376
+ };
377
+
378
+ const pwResultsPath = path.join(
379
+ projectDir,
380
+ "qabot-reports",
381
+ "playwright",
382
+ "results.json",
383
+ );
384
+ if (await fileExists(pwResultsPath)) {
385
+ try {
386
+ const pwResults = JSON.parse(await readFile(pwResultsPath, "utf-8"));
387
+ if (pwResults.suites) {
388
+ const tests = [];
389
+ flattenSuites(pwResults.suites, tests);
390
+ results.results = [
391
+ {
392
+ runner: "playwright",
393
+ layer: "e2e",
394
+ feature,
395
+ tests,
396
+ summary: {
397
+ total: tests.length,
398
+ passed: tests.filter((t) => t.status === "passed").length,
399
+ failed: tests.filter((t) => t.status === "failed").length,
400
+ skipped: tests.filter((t) => t.status === "skipped").length,
401
+ },
402
+ },
403
+ ];
404
+ results.summary.totalTests = tests.length;
405
+ results.summary.totalPassed = tests.filter(
406
+ (t) => t.status === "passed",
407
+ ).length;
408
+ results.summary.totalFailed = tests.filter(
409
+ (t) => t.status === "failed",
410
+ ).length;
411
+ results.summary.overallPassRate =
412
+ tests.length > 0
413
+ ? Math.round((results.summary.totalPassed / tests.length) * 100)
414
+ : 0;
415
+ }
416
+ } catch {
417
+ /* skip malformed results */
418
+ }
419
+ }
420
+
421
+ const reportPaths = await reporter.generate(results, {
422
+ feature,
423
+ environment: env,
424
+ projectName: config.project?.name || "unknown",
425
+ timestamp: new Date().toISOString(),
426
+ duration: 0,
427
+ });
428
+
429
+ logger.blank();
430
+ logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
431
+ } catch {
432
+ /* report generation is best-effort */
433
+ }
434
+ }
435
+
436
+ function flattenSuites(suites, tests) {
437
+ for (const suite of suites || []) {
438
+ for (const spec of suite.specs || []) {
439
+ for (const test of spec.tests || []) {
440
+ const result = test.results?.[test.results.length - 1];
441
+ tests.push({
442
+ name: spec.title,
443
+ suite: suite.title,
444
+ file: suite.file || "",
445
+ status:
446
+ test.status === "expected"
447
+ ? "passed"
448
+ : test.status === "skipped"
449
+ ? "skipped"
450
+ : "failed",
451
+ duration: result?.duration || 0,
452
+ error: result?.error
453
+ ? {
454
+ message: result.error.message || "",
455
+ stack: result.error.stack || "",
456
+ }
457
+ : null,
458
+ screenshots: (result?.attachments || [])
459
+ .filter((a) => a.contentType?.startsWith("image/"))
460
+ .map((a) => a.path),
461
+ });
462
+ }
463
+ }
464
+ if (suite.suites) flattenSuites(suite.suites, tests);
465
+ }
466
+ }
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.5.1";
1
+ export const VERSION = "0.6.0";
2
2
  export const TOOL_NAME = "qabot";
3
3
 
4
4
  export const PROJECT_TYPES = [
@@ -0,0 +1,61 @@
1
+ import { buildE2EPrompt } from "./e2e-prompts.js";
2
+
3
+ export async function generateE2ESpec(ai, featureName, context) {
4
+ const prompt = buildE2EPrompt(featureName, context);
5
+
6
+ const savedMaxTokens = ai.maxTokens;
7
+ ai.maxTokens = Math.max(ai.maxTokens, 8192);
8
+ try {
9
+ const code = await ai.complete(prompt);
10
+ return cleanSpec(code);
11
+ } finally {
12
+ ai.maxTokens = savedMaxTokens;
13
+ }
14
+ }
15
+
16
+ export async function fixE2ESpec(ai, code, errorMessage, context) {
17
+ const prompt = `You are fixing a broken Playwright E2E test.
18
+
19
+ ## Error
20
+ \`\`\`
21
+ ${errorMessage.slice(0, 2000)}
22
+ \`\`\`
23
+
24
+ ## Current test code
25
+ \`\`\`javascript
26
+ ${code}
27
+ \`\`\`
28
+
29
+ ## Context
30
+ - Base URL: ${context.baseUrl || "http://localhost:3000"}
31
+ - Auth: ${context.authProvider || "none"}
32
+
33
+ ## Common Playwright fixes
34
+ 1. Wrong selector — use getByRole, getByText, getByTestId instead of CSS selectors
35
+ 2. Timing — add waitForLoadState("networkidle") or waitForSelector
36
+ 3. Element not visible — scroll into view or wait for animation
37
+ 4. Navigation — ensure page.goto uses correct path
38
+ 5. Auth — ensure login completed before testing
39
+
40
+ Return the COMPLETE fixed test file. No markdown fences.`;
41
+
42
+ const savedMaxTokens = ai.maxTokens;
43
+ ai.maxTokens = Math.max(ai.maxTokens, 8192);
44
+ try {
45
+ const fixed = await ai.complete(prompt);
46
+ return cleanSpec(fixed);
47
+ } finally {
48
+ ai.maxTokens = savedMaxTokens;
49
+ }
50
+ }
51
+
52
+ function cleanSpec(code) {
53
+ let cleaned = code.trim();
54
+ if (cleaned.startsWith("```")) {
55
+ cleaned = cleaned.slice(cleaned.indexOf("\n") + 1);
56
+ }
57
+ if (cleaned.endsWith("```")) {
58
+ cleaned = cleaned.slice(0, cleaned.lastIndexOf("```"));
59
+ }
60
+ return cleaned.trim() + "\n";
61
+ }
@@ -0,0 +1,66 @@
1
+ export function buildE2EPrompt(featureName, context) {
2
+ const sourceSection = context.sourceCode
3
+ ? `\n## Source Code (for understanding page structure)\n\`\`\`\n${context.sourceCode.slice(0, 6000)}\n\`\`\`\n`
4
+ : "";
5
+
6
+ const routeSection = context.route
7
+ ? `\n## Page Route: ${context.route}\n`
8
+ : "";
9
+
10
+ const useCaseSection = context.useCases?.length
11
+ ? `\n## QA Use Cases\n${context.useCases.map((uc) => `### ${uc.scenario}\n${uc.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}`).join("\n\n")}\n`
12
+ : "";
13
+
14
+ return `You are a senior QA automation engineer writing Playwright E2E tests.
15
+
16
+ ## Feature: ${featureName}
17
+ ## Base URL: ${context.baseUrl || "http://localhost:3000"}
18
+ ## Auth Provider: ${context.authProvider || "none"}
19
+ ${routeSection}${sourceSection}${useCaseSection}
20
+ ## Task
21
+ Write a Playwright test spec for the "${featureName}" feature. The test should:
22
+ 1. Navigate to the correct page
23
+ 2. Verify the page loads correctly (key elements visible)
24
+ 3. Test main user interactions
25
+ 4. Take screenshots at key checkpoints
26
+ 5. Verify expected outcomes
27
+
28
+ ## Playwright Rules
29
+ 1. Use \`const { test, expect } = require("@playwright/test");\`
30
+ 2. Use \`test.describe\` for grouping, \`test\` for individual tests
31
+ 3. Use accessible selectors: \`page.getByRole()\`, \`page.getByText()\`, \`page.getByTestId()\`
32
+ 4. Use \`await page.waitForLoadState("networkidle")\` after navigation
33
+ 5. Use \`await page.screenshot({ path: "e2e/screenshots/${featureName}-{step}.png" })\` at key points
34
+ 6. Use \`expect(page).toHaveURL()\` for navigation verification
35
+ 7. Use \`expect(locator).toBeVisible()\` for element verification
36
+ 8. Handle authentication if needed:
37
+ \`\`\`
38
+ const { login } = require("../helpers/auth.js");
39
+ test.beforeEach(async ({ page, baseURL }) => { await login(page, baseURL); });
40
+ \`\`\`
41
+ 9. Each test should be independent
42
+ 10. Add reasonable timeouts for slow operations
43
+
44
+ ## Structure
45
+ \`\`\`
46
+ test.describe("${featureName}", () => {
47
+ test.beforeEach(async ({ page, baseURL }) => {
48
+ // login if needed
49
+ // navigate to feature page
50
+ });
51
+
52
+ test("page loads correctly", async ({ page }) => {
53
+ // verify key elements
54
+ // take screenshot
55
+ });
56
+
57
+ test("main interaction works", async ({ page }) => {
58
+ // perform action
59
+ // verify outcome
60
+ // take screenshot
61
+ });
62
+ });
63
+ \`\`\`
64
+
65
+ Return ONLY the JavaScript code. No markdown fences. No explanation.`;
66
+ }
@@ -0,0 +1,144 @@
1
+ import path from "node:path";
2
+ import { execSync } from "node:child_process";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { fileExists, ensureDir } from "../utils/file-utils.js";
5
+
6
+ export async function ensurePlaywright(projectDir) {
7
+ const pwBin = path.join(projectDir, "node_modules", ".bin", "playwright");
8
+
9
+ if (!(await fileExists(pwBin))) {
10
+ execSync("npm install -D @playwright/test", {
11
+ cwd: projectDir,
12
+ stdio: "pipe",
13
+ });
14
+ }
15
+
16
+ try {
17
+ execSync("npx playwright install chromium", {
18
+ cwd: projectDir,
19
+ stdio: "pipe",
20
+ timeout: 120000,
21
+ });
22
+ } catch {
23
+ execSync("npx playwright install", {
24
+ cwd: projectDir,
25
+ stdio: "pipe",
26
+ timeout: 120000,
27
+ });
28
+ }
29
+
30
+ return true;
31
+ }
32
+
33
+ export async function ensureE2EStructure(projectDir) {
34
+ const dirs = [
35
+ "e2e/tests",
36
+ "e2e/pages",
37
+ "e2e/helpers",
38
+ "e2e/.auth",
39
+ "e2e/screenshots",
40
+ ];
41
+ for (const dir of dirs) {
42
+ await ensureDir(path.join(projectDir, dir));
43
+ }
44
+ }
45
+
46
+ export async function writePlaywrightConfig(projectDir, config) {
47
+ const configPath = path.join(projectDir, "e2e", "playwright.config.js");
48
+ if (await fileExists(configPath)) return configPath;
49
+
50
+ const envUrls = {};
51
+ if (config.environments) {
52
+ for (const [name, env] of Object.entries(config.environments)) {
53
+ if (env.url) envUrls[name] = env.url;
54
+ }
55
+ }
56
+
57
+ const content = `const { defineConfig, devices } = require("@playwright/test");
58
+
59
+ const ENV_URLS = ${JSON.stringify(envUrls, null, 2)};
60
+
61
+ module.exports = defineConfig({
62
+ testDir: "./tests",
63
+ fullyParallel: false,
64
+ retries: 1,
65
+ workers: 1,
66
+ reporter: [
67
+ ["html", { open: "never", outputFolder: "../qabot-reports/playwright" }],
68
+ ["json", { outputFile: "../qabot-reports/playwright/results.json" }],
69
+ ],
70
+ use: {
71
+ baseURL: ENV_URLS[process.env.E2E_ENV || "default"] || "http://localhost:3000",
72
+ trace: "on-first-retry",
73
+ screenshot: "on",
74
+ ignoreHTTPSErrors: true,
75
+ actionTimeout: 10000,
76
+ navigationTimeout: 30000,
77
+ },
78
+ projects: [
79
+ {
80
+ name: "chromium",
81
+ use: { ...devices["Desktop Chrome"] },
82
+ },
83
+ ],
84
+ });
85
+ `;
86
+
87
+ await writeFile(configPath, content, "utf-8");
88
+ return configPath;
89
+ }
90
+
91
+ export async function writeAuthHelper(projectDir, config) {
92
+ const helperPath = path.join(projectDir, "e2e", "helpers", "auth.js");
93
+ if (await fileExists(helperPath)) return;
94
+
95
+ const authProvider = config.auth?.provider || "none";
96
+ const content = `const { expect } = require("@playwright/test");
97
+
98
+ async function login(page, baseURL) {
99
+ const email = process.env.E2E_TEST_EMAIL || "";
100
+ const password = process.env.E2E_TEST_PASSWORD || "";
101
+
102
+ if (!email || !password) {
103
+ console.warn("E2E_TEST_EMAIL or E2E_TEST_PASSWORD not set — skipping login");
104
+ return;
105
+ }
106
+
107
+ await page.goto(baseURL || "/");
108
+ await page.waitForLoadState("networkidle");
109
+
110
+ ${
111
+ authProvider === "auth0"
112
+ ? `
113
+ const signInBtn = page.getByRole("button", { name: /sign in/i })
114
+ .or(page.getByRole("link", { name: /sign in/i }))
115
+ .or(page.locator("[data-testid='sign-in-button']"));
116
+
117
+ if (await signInBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
118
+ await signInBtn.click();
119
+ await page.waitForLoadState("networkidle");
120
+ }
121
+
122
+ const emailInput = page.getByLabel(/email/i).or(page.locator("input[name='email']")).or(page.locator("input[type='email']"));
123
+ if (await emailInput.isVisible({ timeout: 5000 }).catch(() => false)) {
124
+ await emailInput.fill(email);
125
+ const passwordInput = page.getByLabel(/password/i).or(page.locator("input[name='password']")).or(page.locator("input[type='password']"));
126
+ await passwordInput.fill(password);
127
+ const submitBtn = page.getByRole("button", { name: /continue|log in|sign in|submit/i });
128
+ await submitBtn.click();
129
+ await page.waitForLoadState("networkidle");
130
+ }`
131
+ : `
132
+ // Generic login — customize for your auth provider
133
+ await page.getByLabel(/email/i).fill(email);
134
+ await page.getByLabel(/password/i).fill(password);
135
+ await page.getByRole("button", { name: /sign in|log in|submit/i }).click();
136
+ await page.waitForLoadState("networkidle");`
137
+ }
138
+ }
139
+
140
+ module.exports = { login };
141
+ `;
142
+
143
+ await writeFile(helperPath, content, "utf-8");
144
+ }