@nhonh/qabot 0.6.1 → 1.0.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
@@ -9,20 +9,89 @@ 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
11
  import { registerTestCommand } from "../src/cli/commands/test.js";
12
+ import { runInteractive } from "../src/cli/interactive.js";
12
13
 
13
- const program = new Command();
14
+ const hasSubcommand =
15
+ process.argv.length > 2 && !process.argv[2].startsWith("-");
14
16
 
15
- program
16
- .name(TOOL_NAME)
17
- .description("AI-powered universal QA automation tool")
18
- .version(VERSION);
17
+ if (!hasSubcommand) {
18
+ runInteractive(process.cwd())
19
+ .then(async (result) => {
20
+ if (!result) process.exit(0);
19
21
 
20
- registerInitCommand(program);
21
- registerRunCommand(program);
22
- registerTestCommand(program);
23
- registerListCommand(program);
24
- registerGenerateCommand(program);
25
- registerReportCommand(program);
26
- registerAuthCommand(program);
22
+ switch (result.action) {
23
+ case "init": {
24
+ const { runInit } = await import("../src/cli/commands/init.js");
25
+ if (typeof runInit === "function")
26
+ await runInit({ yes: true, dir: process.cwd() });
27
+ else process.argv.push("init", "-y");
28
+ break;
29
+ }
30
+ case "report": {
31
+ process.argv.push("report");
32
+ break;
33
+ }
34
+ case "auth": {
35
+ process.argv.push("auth");
36
+ break;
37
+ }
38
+ case "e2e": {
39
+ const args = ["test"];
40
+ if (result.feature) args.push(result.feature);
41
+ if (result.url) args.push("--url", result.url);
42
+ if (result.headed) args.push("--headed");
43
+ process.argv.push(...args);
44
+ break;
45
+ }
46
+ case "unit": {
47
+ const args = ["run"];
48
+ if (result.feature) args.push(result.feature);
49
+ args.push("--layer", "unit");
50
+ process.argv.push(...args);
51
+ break;
52
+ }
53
+ case "generate": {
54
+ const args = ["generate"];
55
+ if (result.feature) args.push(result.feature);
56
+ process.argv.push(...args);
57
+ break;
58
+ }
59
+ case "full": {
60
+ const args = ["run"];
61
+ if (result.feature) args.push(result.feature);
62
+ process.argv.push(...args);
63
+ break;
64
+ }
65
+ }
27
66
 
28
- program.parse();
67
+ if (process.argv.length > 2) {
68
+ const program = buildProgram();
69
+ program.parse();
70
+ }
71
+ })
72
+ .catch((err) => {
73
+ console.error(err.message);
74
+ process.exit(1);
75
+ });
76
+ } else {
77
+ const program = buildProgram();
78
+ program.parse();
79
+ }
80
+
81
+ function buildProgram() {
82
+ const program = new Command();
83
+ program
84
+ .name(TOOL_NAME)
85
+ .description("AI-powered universal QA automation tool")
86
+ .version(VERSION);
87
+
88
+ registerInitCommand(program);
89
+ registerRunCommand(program);
90
+ registerTestCommand(program);
91
+ registerListCommand(program);
92
+ registerGenerateCommand(program);
93
+ registerReportCommand(program);
94
+ registerAuthCommand(program);
95
+
96
+ return program;
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nhonh/qabot",
3
- "version": "0.6.1",
3
+ "version": "1.0.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": {
@@ -2,8 +2,9 @@ import chalk from "chalk";
2
2
  import ora from "ora";
3
3
  import path from "node:path";
4
4
  import { writeFile, readFile } from "node:fs/promises";
5
- import { execSync } from "node:child_process";
6
- import { logger } from "../../core/logger.js";
5
+ import { execSync, spawn } from "node:child_process";
6
+ import Enquirer from "enquirer";
7
+ import { logger, formatMs } from "../../core/logger.js";
7
8
  import { loadConfig } from "../../core/config.js";
8
9
  import { analyzeProject } from "../../analyzers/project-analyzer.js";
9
10
  import { AIEngine } from "../../ai/ai-engine.js";
@@ -23,6 +24,14 @@ import {
23
24
  } from "../../utils/file-utils.js";
24
25
  import { ReportGenerator } from "../../reporter/report-generator.js";
25
26
 
27
+ const V = chalk.hex("#A78BFA");
28
+ const V2 = chalk.hex("#7C3AED");
29
+ const DIM = chalk.hex("#6B7280");
30
+ const W = chalk.hex("#F3F4F6");
31
+ const G = chalk.hex("#34D399");
32
+ const R = chalk.hex("#F87171");
33
+ const Y = chalk.hex("#FBBF24");
34
+
26
35
  const MAX_FIX_ATTEMPTS = 3;
27
36
 
28
37
  export function registerTestCommand(program) {
@@ -54,26 +63,25 @@ async function runTest(feature, options) {
54
63
  config.environments?.[options.env] || config.environments?.default;
55
64
  const baseUrl = options.url || envConfig?.url || "http://localhost:3000";
56
65
 
57
- logger.header("QABot \u2014 E2E Automation Test");
66
+ logger.header("E2E Automation Test");
58
67
  logger.blank();
59
- logger.info(`Feature: ${chalk.bold(feature || "all")}`);
60
- logger.info(`Target: ${chalk.bold.cyan(baseUrl)}`);
61
- logger.info(`Environment: ${chalk.bold(options.env)}`);
62
- logger.info(
63
- `Browser: ${options.headed ? "Headed (visible)" : "Headless"}`,
64
- );
68
+ console.log(DIM(" Feature: ") + W(feature || "all"));
69
+ console.log(DIM(" Target: ") + chalk.hex("#22D3EE")(baseUrl));
70
+ console.log(DIM(" Browser: ") + W(options.headed ? "Headed" : "Headless"));
65
71
  logger.blank();
66
- console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
67
72
 
68
- const spinner = ora("Setting up Playwright...").start();
73
+ const spinner = ora({
74
+ text: V(" Setting up Playwright..."),
75
+ spinner: "dots",
76
+ }).start();
69
77
  try {
70
78
  await ensurePlaywright(projectDir);
71
79
  await ensureE2EStructure(projectDir);
72
80
  await writePlaywrightConfig(projectDir, config);
73
81
  await writeAuthHelper(projectDir, config);
74
- spinner.succeed("Playwright ready");
82
+ spinner.succeed(G(" Playwright ready"));
75
83
  } catch (err) {
76
- spinner.fail(`Playwright setup failed: ${err.message}`);
84
+ spinner.fail(R(` Setup failed: ${err.message}`));
77
85
  return;
78
86
  }
79
87
 
@@ -84,22 +92,15 @@ async function runTest(feature, options) {
84
92
  if (options.skipGen) {
85
93
  const existing = await findFiles(specDir, "*.spec.js");
86
94
  if (existing.length === 0) {
87
- logger.error(
88
- "No existing E2E specs found. Run without --skip-gen to generate.",
89
- );
95
+ logger.error("No existing E2E specs found. Run without --skip-gen.");
90
96
  return;
91
97
  }
92
98
  specFile = feature
93
99
  ? existing.find((f) => f.toLowerCase().includes(feature.toLowerCase()))
94
100
  : null;
95
- logger.info(
96
- `Running ${specFile ? path.basename(specFile) : "all"} existing specs`,
97
- );
98
101
  } else {
99
102
  if (!feature) {
100
- logger.error(
101
- "Feature name required for test generation. Usage: qabot test <feature>",
102
- );
103
+ logger.error("Feature name required. Usage: qabot test <feature>");
103
104
  return;
104
105
  }
105
106
 
@@ -112,9 +113,10 @@ async function runTest(feature, options) {
112
113
  return;
113
114
  }
114
115
 
115
- const spinner2 = ora(
116
- "AI is analyzing feature and generating E2E tests...",
117
- ).start();
116
+ const spinner2 = ora({
117
+ text: V(" AI analyzing feature..."),
118
+ spinner: "dots",
119
+ }).start();
118
120
 
119
121
  let sourceCode = "";
120
122
  if (featureConfig?.src) {
@@ -159,183 +161,225 @@ async function runTest(feature, options) {
159
161
  authProvider: config.auth?.provider || "none",
160
162
  useCases,
161
163
  });
162
-
163
164
  specFile = path.join(specDir, `${feature}.spec.js`);
164
165
  await writeFile(specFile, spec, "utf-8");
165
166
  spinner2.succeed(
166
- `E2E spec generated: ${chalk.underline(path.relative(projectDir, specFile))}`,
167
+ G(` E2E spec: ${chalk.underline(path.relative(projectDir, specFile))}`),
167
168
  );
168
169
  } catch (err) {
169
- spinner2.fail(`E2E generation failed: ${err.message}`);
170
+ spinner2.fail(R(` Generation failed: ${err.message}`));
170
171
  return;
171
172
  }
172
173
 
173
174
  if (options.fix !== false) {
174
- const aiForFix = ai;
175
-
176
175
  for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
177
176
  logger.blank();
178
- logger.step(
179
- attempt,
180
- MAX_FIX_ATTEMPTS,
181
- `Running E2E tests (attempt ${attempt})...`,
182
- );
177
+ logger.step(attempt, MAX_FIX_ATTEMPTS, `Attempt ${attempt}`);
178
+ logger.blank();
183
179
 
184
- const {
185
- exitCode,
186
- error: testError,
187
- stdout,
188
- } = runPlaywright(projectDir, specFile, options);
180
+ const result = await runPlaywrightStreaming(
181
+ projectDir,
182
+ specFile,
183
+ options,
184
+ baseUrl,
185
+ );
189
186
 
190
- if (exitCode === 0) {
191
- printPlaywrightSummary(stdout);
192
- logger.blank();
193
- logger.success(`E2E tests passed on attempt ${attempt}!`);
187
+ if (result.exitCode === 0) {
188
+ logger.summary(
189
+ result.passed,
190
+ result.failed,
191
+ result.skipped,
192
+ result.duration,
193
+ );
194
+ if (result.passed > 0)
195
+ logger.success(`E2E passed on attempt ${attempt}`);
194
196
  await generateE2EReport(
195
197
  projectDir,
196
198
  config,
197
199
  feature,
198
200
  options.env,
199
201
  baseUrl,
202
+ result,
200
203
  );
201
204
  return;
202
205
  }
203
206
 
204
207
  if (attempt >= MAX_FIX_ATTEMPTS) {
205
- printPlaywrightSummary(stdout || testError);
206
- logger.blank();
207
- logger.warn(
208
- `E2E tests still failing after ${MAX_FIX_ATTEMPTS} attempts.`,
208
+ logger.summary(
209
+ result.passed,
210
+ result.failed,
211
+ result.skipped,
212
+ result.duration,
209
213
  );
210
- logger.dim(` Review: cat ${path.relative(projectDir, specFile)}`);
211
- logger.dim(` Debug: qabot test ${feature} --headed`);
214
+ logger.warn(`Still failing after ${MAX_FIX_ATTEMPTS} attempts`);
215
+ logger.dim(`Review: cat ${path.relative(projectDir, specFile)}`);
216
+ logger.dim(`Debug: qabot test ${feature} --headed`);
212
217
  await generateE2EReport(
213
218
  projectDir,
214
219
  config,
215
220
  feature,
216
221
  options.env,
217
222
  baseUrl,
223
+ result,
218
224
  );
219
225
  return;
220
226
  }
221
227
 
222
- logger.dim(` ${extractFirstError(testError || stdout)}`);
223
- logger.step(attempt, MAX_FIX_ATTEMPTS, "AI is fixing E2E test...");
224
-
228
+ const fixSpinner = ora({
229
+ text: V(" AI fixing errors..."),
230
+ spinner: "dots",
231
+ }).start();
225
232
  try {
226
233
  const currentCode = await readFile(specFile, "utf-8");
227
- const fixedCode = await fixE2ESpec(
228
- aiForFix,
229
- currentCode,
230
- testError || stdout,
231
- {
232
- baseUrl,
233
- authProvider: config.auth?.provider,
234
- },
235
- );
234
+ const fixedCode = await fixE2ESpec(ai, currentCode, result.rawError, {
235
+ baseUrl,
236
+ authProvider: config.auth?.provider,
237
+ });
236
238
  await writeFile(specFile, fixedCode, "utf-8");
237
- logger.dim(" Fix applied. Re-running...");
239
+ fixSpinner.succeed(G(" Fix applied"));
238
240
  } catch (err) {
239
- logger.warn(` AI fix failed: ${err.message}`);
241
+ fixSpinner.fail(R(` Fix failed: ${err.message}`));
240
242
  break;
241
243
  }
242
244
  }
245
+ return;
243
246
  }
244
247
  }
245
248
 
246
249
  if (!specFile && !options.skipGen) return;
247
250
 
248
- logger.blank();
249
- logger.info("Running E2E tests...");
250
-
251
- const {
252
- exitCode,
253
- stdout,
254
- error: testError,
255
- } = runPlaywright(projectDir, specFile, options);
256
- printPlaywrightSummary(stdout || testError);
251
+ logger.section("Running E2E");
252
+ const result = await runPlaywrightStreaming(
253
+ projectDir,
254
+ specFile,
255
+ options,
256
+ baseUrl,
257
+ );
258
+ logger.summary(result.passed, result.failed, result.skipped, result.duration);
257
259
  await generateE2EReport(
258
260
  projectDir,
259
261
  config,
260
262
  feature || "all",
261
263
  options.env,
262
264
  baseUrl,
265
+ result,
263
266
  );
264
-
265
- if (exitCode !== 0) process.exit(1);
267
+ if (result.failed > 0) process.exit(1);
266
268
  }
267
269
 
268
- function runPlaywright(projectDir, specFile, options) {
269
- const configPath = path.join(projectDir, "e2e", "playwright.config.js");
270
- const parts = ["npx", "playwright", "test"];
271
- if (specFile)
272
- parts.push(path.relative(path.join(projectDir, "e2e"), specFile));
273
- parts.push(`--config="${configPath}"`);
274
- parts.push("--project=chromium");
275
- if (options.headed) parts.push("--headed");
276
-
277
- const env = {
278
- ...process.env,
279
- E2E_ENV: options.env || "default",
280
- FORCE_COLOR: "0",
281
- };
270
+ function runPlaywrightStreaming(projectDir, specFile, options, baseUrl) {
271
+ return new Promise((resolve) => {
272
+ const configPath = path.join(projectDir, "e2e", "playwright.config.js");
273
+ const args = ["playwright", "test"];
274
+ if (specFile)
275
+ args.push(path.relative(path.join(projectDir, "e2e"), specFile));
276
+ args.push(
277
+ `--config=${configPath}`,
278
+ "--project=chromium",
279
+ "--reporter=line",
280
+ );
281
+ if (options.headed) args.push("--headed");
282
282
 
283
- try {
284
- const stdout = execSync(parts.join(" "), {
283
+ const env = {
284
+ ...process.env,
285
+ E2E_ENV: options.env || "default",
286
+ BASE_URL: baseUrl,
287
+ FORCE_COLOR: "1",
288
+ };
289
+
290
+ const startTime = Date.now();
291
+ let stdout = "";
292
+ let stderr = "";
293
+ let passed = 0;
294
+ let failed = 0;
295
+ let skipped = 0;
296
+ let testIndex = 0;
297
+
298
+ const child = spawn("npx", args, {
285
299
  cwd: projectDir,
286
- stdio: "pipe",
287
- timeout: 120000,
288
300
  env,
289
- }).toString();
290
- return { exitCode: 0, stdout, error: "" };
291
- } catch (err) {
292
- return {
293
- exitCode: err.status || 1,
294
- stdout: err.stdout?.toString() || "",
295
- error: err.stderr?.toString() || err.stdout?.toString() || "",
296
- };
297
- }
301
+ stdio: ["pipe", "pipe", "pipe"],
302
+ });
303
+
304
+ child.stdout.on("data", (data) => {
305
+ const text = data.toString();
306
+ stdout += text;
307
+
308
+ for (const line of text.split("\n")) {
309
+ const trimmed = line.trim();
310
+ if (!trimmed) continue;
311
+
312
+ if (
313
+ trimmed.includes("\u2713") ||
314
+ trimmed.includes("passed") ||
315
+ trimmed.match(/\[.*\].*ok/i)
316
+ ) {
317
+ testIndex++;
318
+ passed++;
319
+ const name = extractTestName(trimmed);
320
+ logger.testResult(name, "passed", extractDuration(trimmed));
321
+ } else if (
322
+ trimmed.includes("\u2717") ||
323
+ trimmed.includes("failed") ||
324
+ trimmed.includes("FAIL")
325
+ ) {
326
+ testIndex++;
327
+ failed++;
328
+ const name = extractTestName(trimmed);
329
+ logger.testResult(name, "failed", extractDuration(trimmed));
330
+ } else if (trimmed.includes("skipped")) {
331
+ testIndex++;
332
+ skipped++;
333
+ const name = extractTestName(trimmed);
334
+ logger.testResult(name, "skipped", 0);
335
+ } else if (trimmed.match(/Running \d+ test/)) {
336
+ logger.dim(trimmed);
337
+ } else if (trimmed.includes("Error") || trimmed.includes("expect")) {
338
+ logger.dim(R(` ${trimmed.slice(0, 120)}`));
339
+ }
340
+ }
341
+ });
342
+
343
+ child.stderr.on("data", (data) => {
344
+ stderr += data.toString();
345
+ });
346
+
347
+ child.on("close", (exitCode) => {
348
+ const duration = Date.now() - startTime;
349
+ resolve({
350
+ exitCode: exitCode ?? 1,
351
+ passed,
352
+ failed,
353
+ skipped,
354
+ duration,
355
+ stdout,
356
+ stderr,
357
+ rawError: (stderr || stdout).slice(0, 4000),
358
+ tests: [],
359
+ });
360
+ });
361
+ });
298
362
  }
299
363
 
300
- function printPlaywrightSummary(output) {
301
- if (!output) return;
302
- const lines = output.split("\n");
303
- for (const line of lines) {
304
- const trimmed = line.trim();
305
- if (!trimmed) continue;
306
- if (trimmed.includes("\u2713") || trimmed.includes("passed")) {
307
- logger.success(trimmed);
308
- } else if (
309
- trimmed.includes("\u2717") ||
310
- trimmed.includes("failed") ||
311
- trimmed.includes("Error")
312
- ) {
313
- logger.error(trimmed);
314
- } else if (
315
- trimmed.includes("─") ||
316
- trimmed.includes("Running") ||
317
- trimmed.includes("test")
318
- ) {
319
- logger.dim(trimmed);
320
- }
321
- }
364
+ function extractTestName(line) {
365
+ const cleaned = line
366
+ .replace(/\u2713|\u2717|\u25CB/g, "")
367
+ .replace(/\[.*?\]/g, "")
368
+ .replace(/\(\d+(\.\d+)?[ms]+\)/g, "")
369
+ .replace(/›/g, " > ")
370
+ .replace(/\s+/g, " ")
371
+ .trim();
372
+ const parts = cleaned.split(" > ");
373
+ return parts[parts.length - 1] || cleaned;
322
374
  }
323
375
 
324
- function extractFirstError(output) {
325
- if (!output) return "Unknown error";
326
- const lines = output.split("\n").filter((l) => l.trim());
327
- const errorLine = lines.find(
328
- (l) =>
329
- l.includes("Error") ||
330
- l.includes("expect") ||
331
- l.includes("Timeout") ||
332
- l.includes("locator"),
333
- );
334
- return (
335
- errorLine?.trim().slice(0, 150) ||
336
- lines[0]?.trim().slice(0, 150) ||
337
- "Test failed"
338
- );
376
+ function extractDuration(line) {
377
+ const match = line.match(/\((\d+(?:\.\d+)?)(ms|s|m)\)/);
378
+ if (!match) return 0;
379
+ const val = parseFloat(match[1]);
380
+ if (match[2] === "s") return val * 1000;
381
+ if (match[2] === "m") return val * 60000;
382
+ return val;
339
383
  }
340
384
 
341
385
  function guessRoute(featureName) {
@@ -356,112 +400,67 @@ function guessRoute(featureName) {
356
400
  return routes[featureName] || `/${featureName}`;
357
401
  }
358
402
 
359
- async function generateE2EReport(projectDir, config, feature, env, baseUrl) {
403
+ async function generateE2EReport(
404
+ projectDir,
405
+ config,
406
+ feature,
407
+ env,
408
+ baseUrl,
409
+ result,
410
+ ) {
360
411
  try {
361
- const screenshotsDir = path.join(projectDir, "e2e", "screenshots");
362
- const screenshots = await findFiles(screenshotsDir, "*.png").catch(
363
- () => [],
364
- );
365
-
366
412
  const reporter = new ReportGenerator(config);
413
+ const tests = result.tests || [];
367
414
  const results = {
368
415
  summary: {
369
- totalTests: 0,
370
- totalPassed: 0,
371
- totalFailed: 0,
372
- totalSkipped: 0,
373
- overallPassRate: 0,
374
- byLayer: {},
416
+ totalTests: result.passed + result.failed + result.skipped,
417
+ totalPassed: result.passed,
418
+ totalFailed: result.failed,
419
+ totalSkipped: result.skipped,
420
+ overallPassRate:
421
+ result.passed + result.failed + result.skipped > 0
422
+ ? Math.round(
423
+ (result.passed /
424
+ (result.passed + result.failed + result.skipped)) *
425
+ 100,
426
+ )
427
+ : 0,
428
+ totalDuration: result.duration,
429
+ byLayer: {
430
+ e2e: {
431
+ total: result.passed + result.failed + result.skipped,
432
+ passed: result.passed,
433
+ failed: result.failed,
434
+ skipped: result.skipped,
435
+ },
436
+ },
375
437
  },
376
- results: [],
438
+ results: [
439
+ {
440
+ runner: "playwright",
441
+ layer: "e2e",
442
+ feature,
443
+ tests,
444
+ summary: {
445
+ total: result.passed + result.failed + result.skipped,
446
+ passed: result.passed,
447
+ failed: result.failed,
448
+ skipped: result.skipped,
449
+ },
450
+ },
451
+ ],
377
452
  };
378
453
 
379
- const pwResultsPath = path.join(
380
- projectDir,
381
- "qabot-reports",
382
- "playwright",
383
- "results.json",
384
- );
385
- if (await fileExists(pwResultsPath)) {
386
- try {
387
- const pwResults = JSON.parse(await readFile(pwResultsPath, "utf-8"));
388
- if (pwResults.suites) {
389
- const tests = [];
390
- flattenSuites(pwResults.suites, tests);
391
- results.results = [
392
- {
393
- runner: "playwright",
394
- layer: "e2e",
395
- feature,
396
- tests,
397
- summary: {
398
- total: tests.length,
399
- passed: tests.filter((t) => t.status === "passed").length,
400
- failed: tests.filter((t) => t.status === "failed").length,
401
- skipped: tests.filter((t) => t.status === "skipped").length,
402
- },
403
- },
404
- ];
405
- results.summary.totalTests = tests.length;
406
- results.summary.totalPassed = tests.filter(
407
- (t) => t.status === "passed",
408
- ).length;
409
- results.summary.totalFailed = tests.filter(
410
- (t) => t.status === "failed",
411
- ).length;
412
- results.summary.overallPassRate =
413
- tests.length > 0
414
- ? Math.round((results.summary.totalPassed / tests.length) * 100)
415
- : 0;
416
- }
417
- } catch {
418
- /* skip malformed results */
419
- }
420
- }
421
-
422
454
  const reportPaths = await reporter.generate(results, {
423
455
  feature,
424
456
  environment: env,
425
457
  projectName: config.project?.name || "unknown",
426
458
  timestamp: new Date().toISOString(),
427
- duration: 0,
459
+ duration: result.duration,
428
460
  });
429
461
 
430
- logger.blank();
431
462
  logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
432
463
  } catch {
433
- /* report generation is best-effort */
434
- }
435
- }
436
-
437
- function flattenSuites(suites, tests) {
438
- for (const suite of suites || []) {
439
- for (const spec of suite.specs || []) {
440
- for (const test of spec.tests || []) {
441
- const result = test.results?.[test.results.length - 1];
442
- tests.push({
443
- name: spec.title,
444
- suite: suite.title,
445
- file: suite.file || "",
446
- status:
447
- test.status === "expected"
448
- ? "passed"
449
- : test.status === "skipped"
450
- ? "skipped"
451
- : "failed",
452
- duration: result?.duration || 0,
453
- error: result?.error
454
- ? {
455
- message: result.error.message || "",
456
- stack: result.error.stack || "",
457
- }
458
- : null,
459
- screenshots: (result?.attachments || [])
460
- .filter((a) => a.contentType?.startsWith("image/"))
461
- .map((a) => a.path),
462
- });
463
- }
464
- }
465
- if (suite.suites) flattenSuites(suite.suites, tests);
464
+ /* best-effort */
466
465
  }
467
466
  }
@@ -0,0 +1,211 @@
1
+ import Enquirer from "enquirer";
2
+ import chalk from "chalk";
3
+ import { logger } from "../core/logger.js";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { analyzeProject } from "../analyzers/project-analyzer.js";
6
+ import { VERSION } from "../core/constants.js";
7
+
8
+ const V = chalk.hex("#A78BFA");
9
+ const V2 = chalk.hex("#7C3AED");
10
+ const DIM = chalk.hex("#6B7280");
11
+ const W = chalk.hex("#F3F4F6");
12
+ const G = chalk.hex("#34D399");
13
+
14
+ export async function runInteractive(projectDir) {
15
+ const enquirer = new Enquirer();
16
+ const { config, isEmpty } = await loadConfig(projectDir);
17
+
18
+ logger.banner();
19
+
20
+ if (isEmpty) {
21
+ logger.warn("No qabot.config.json found in this directory.");
22
+ logger.blank();
23
+ const { shouldInit } = await enquirer.prompt({
24
+ type: "confirm",
25
+ name: "shouldInit",
26
+ message: V("Initialize QABot for this project?"),
27
+ initial: true,
28
+ });
29
+ if (shouldInit) return { action: "init" };
30
+ return null;
31
+ }
32
+
33
+ const profile = await analyzeProject(projectDir);
34
+ const projectName = config.project?.name || profile.name;
35
+ const allFeatures = Object.keys(config.features || {});
36
+
37
+ console.log(DIM(" Project: ") + W(projectName));
38
+ console.log(DIM(" Type: ") + W(profile.type));
39
+ console.log(
40
+ DIM(" Tests: ") + W(`${allFeatures.length} features detected`),
41
+ );
42
+ logger.blank();
43
+ console.log(V2(" " + "\u2500".repeat(48)));
44
+ logger.blank();
45
+
46
+ const { testType } = await enquirer.prompt({
47
+ type: "select",
48
+ name: "testType",
49
+ message: V("\u25C6") + W(" What would you like to do?"),
50
+ pointer: V("\u25B8 "),
51
+ choices: [
52
+ {
53
+ name: "e2e",
54
+ message: `${G("\u25CF")} E2E Browser Test ${DIM("Playwright automation on live app")}`,
55
+ },
56
+ {
57
+ name: "unit",
58
+ message: `${chalk.hex("#60A5FA")("\u25CF")} Unit Test ${DIM("Jest / Vitest in terminal")}`,
59
+ },
60
+ {
61
+ name: "generate",
62
+ message: `${chalk.hex("#FBBF24")("\u25CF")} AI Generate + Run ${DIM("AI writes tests, then runs them")}`,
63
+ },
64
+ {
65
+ name: "full",
66
+ message: `${chalk.hex("#C084FC")("\u25CF")} Full Suite ${DIM("Unit + E2E combined")}`,
67
+ },
68
+ {
69
+ name: "report",
70
+ message: `${chalk.hex("#22D3EE")("\u25CF")} View Report ${DIM("Open last HTML report")}`,
71
+ },
72
+ {
73
+ name: "auth",
74
+ message: `${DIM("\u25CF")} Configure AI Provider ${DIM("Setup OpenAI/Claude/Proxy")}`,
75
+ },
76
+ ],
77
+ });
78
+
79
+ if (testType === "report") return { action: "report" };
80
+ if (testType === "auth") return { action: "auth" };
81
+
82
+ logger.blank();
83
+
84
+ const featureChoices = [
85
+ {
86
+ name: "__all__",
87
+ message: chalk.bold("All features") + DIM(` (${allFeatures.length})`),
88
+ },
89
+ ...allFeatures.slice(0, 30).map((f) => {
90
+ const src = config.features[f]?.src || "";
91
+ const priority = config.features[f]?.priority || "";
92
+ const pColor =
93
+ priority === "P0"
94
+ ? chalk.hex("#F87171")
95
+ : priority === "P1"
96
+ ? chalk.hex("#FBBF24")
97
+ : DIM;
98
+ return {
99
+ name: f,
100
+ message: `${pColor(priority || " ")} ${W(f)} ${DIM(src)}`,
101
+ };
102
+ }),
103
+ ];
104
+
105
+ const { feature } = await enquirer.prompt({
106
+ type: "select",
107
+ name: "feature",
108
+ message: V("\u25C6") + W(" Select feature"),
109
+ pointer: V("\u25B8 "),
110
+ choices: featureChoices,
111
+ limit: 18,
112
+ });
113
+
114
+ const selectedFeature = feature === "__all__" ? null : feature;
115
+
116
+ let targetUrl = null;
117
+ let headed = false;
118
+
119
+ if (testType === "e2e" || testType === "full") {
120
+ logger.blank();
121
+
122
+ const envEntries = Object.entries(config.environments || {});
123
+ const envChoices = envEntries.map(([name, env]) => ({
124
+ name: env.url || name,
125
+ message: `${V("\u25CF")} ${W(name.padEnd(16))} ${DIM(env.url || "")}`,
126
+ }));
127
+ envChoices.push({
128
+ name: "__custom__",
129
+ message: `${chalk.hex("#FBBF24")("\u25CF")} ${W("Custom URL")} ${DIM("Enter URL manually")}`,
130
+ });
131
+
132
+ const { envUrl } = await enquirer.prompt({
133
+ type: "select",
134
+ name: "envUrl",
135
+ message: V("\u25C6") + W(" Target environment"),
136
+ pointer: V("\u25B8 "),
137
+ choices: envChoices,
138
+ limit: 12,
139
+ });
140
+
141
+ if (envUrl === "__custom__") {
142
+ const { customUrl } = await enquirer.prompt({
143
+ type: "input",
144
+ name: "customUrl",
145
+ message: V("\u25C6") + W(" Enter URL"),
146
+ validate: (v) =>
147
+ v.startsWith("http")
148
+ ? true
149
+ : "URL must start with http:// or https://",
150
+ });
151
+ targetUrl = customUrl;
152
+ } else {
153
+ targetUrl = envUrl;
154
+ }
155
+
156
+ logger.blank();
157
+
158
+ const { browserMode } = await enquirer.prompt({
159
+ type: "select",
160
+ name: "browserMode",
161
+ message: V("\u25C6") + W(" Browser mode"),
162
+ pointer: V("\u25B8 "),
163
+ choices: [
164
+ {
165
+ name: "headless",
166
+ message: `${DIM("\u25CF")} Headless ${DIM("Fast, runs in background")}`,
167
+ },
168
+ {
169
+ name: "headed",
170
+ message: `${G("\u25CF")} Headed ${DIM("Watch the browser live")}`,
171
+ },
172
+ ],
173
+ });
174
+ headed = browserMode === "headed";
175
+ }
176
+
177
+ logger.blank();
178
+ console.log(V2(" " + "\u2500".repeat(48)));
179
+ logger.blank();
180
+
181
+ const summary = [];
182
+ summary.push(DIM(" Action: ") + V(testType.toUpperCase()));
183
+ summary.push(DIM(" Feature: ") + W(selectedFeature || "all"));
184
+ if (targetUrl)
185
+ summary.push(DIM(" URL: ") + chalk.hex("#22D3EE")(targetUrl));
186
+ if (testType === "e2e" || testType === "full")
187
+ summary.push(
188
+ DIM(" Browser: ") + W(headed ? "Headed (visible)" : "Headless"),
189
+ );
190
+ summary.forEach((l) => console.log(l));
191
+ logger.blank();
192
+
193
+ const { confirm } = await enquirer.prompt({
194
+ type: "confirm",
195
+ name: "confirm",
196
+ message: V("\u25C6") + G(" Ready to run?"),
197
+ initial: true,
198
+ });
199
+
200
+ if (!confirm) {
201
+ logger.dim("Cancelled.");
202
+ return null;
203
+ }
204
+
205
+ return {
206
+ action: testType,
207
+ feature: selectedFeature,
208
+ url: targetUrl,
209
+ headed,
210
+ };
211
+ }
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.6.1";
1
+ export const VERSION = "1.0.0";
2
2
  export const TOOL_NAME = "qabot";
3
3
 
4
4
  export const PROJECT_TYPES = [
@@ -1,13 +1,13 @@
1
1
  import chalk from "chalk";
2
2
 
3
- const ICONS = {
4
- info: "\u2139",
5
- success: "\u2713",
6
- warn: "\u26a0",
7
- error: "\u2717",
8
- step: "\u25b8",
9
- bullet: "\u2022",
10
- };
3
+ const V = chalk.hex("#A78BFA");
4
+ const V2 = chalk.hex("#7C3AED");
5
+ const V3 = chalk.hex("#C4B5FD");
6
+ const G = chalk.hex("#34D399");
7
+ const R = chalk.hex("#F87171");
8
+ const Y = chalk.hex("#FBBF24");
9
+ const DIM = chalk.hex("#6B7280");
10
+ const W = chalk.hex("#F3F4F6");
11
11
 
12
12
  function formatMs(ms) {
13
13
  if (ms < 1000) return `${ms}ms`;
@@ -17,55 +17,74 @@ function formatMs(ms) {
17
17
 
18
18
  export const logger = {
19
19
  info(msg) {
20
- console.log(chalk.blue(` ${ICONS.info} ${msg}`));
20
+ console.log(V(` \u25CF `) + W(msg));
21
21
  },
22
22
  success(msg) {
23
- console.log(chalk.green(` ${ICONS.success} ${msg}`));
23
+ console.log(G(` \u2714 `) + W(msg));
24
24
  },
25
25
  warn(msg) {
26
- console.log(chalk.yellow(` ${ICONS.warn} ${msg}`));
26
+ console.log(Y(` \u25B2 `) + Y(msg));
27
27
  },
28
28
  error(msg) {
29
- console.log(chalk.red(` ${ICONS.error} ${msg}`));
29
+ console.log(R(` \u2716 `) + R(msg));
30
30
  },
31
31
 
32
32
  step(current, total, msg) {
33
- console.log(
34
- chalk.cyan(
35
- ` ${ICONS.step} ${chalk.dim(`[${current}/${total}]`)} ${msg}`,
36
- ),
37
- );
33
+ const bar = progressBar(current, total, 12);
34
+ console.log(V(` ${bar} `) + W(msg));
38
35
  },
39
36
 
40
37
  dim(msg) {
41
- console.log(chalk.dim(` ${msg}`));
38
+ console.log(DIM(` ${msg}`));
42
39
  },
43
40
  blank() {
44
41
  console.log("");
45
42
  },
46
43
 
44
+ banner() {
45
+ logger.blank();
46
+ console.log(
47
+ V2(" \u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588") +
48
+ W(" QABot"),
49
+ );
50
+ console.log(
51
+ V(" \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588") +
52
+ DIM(" AI-Powered QA Automation"),
53
+ );
54
+ console.log(
55
+ V3(
56
+ " \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588",
57
+ ),
58
+ );
59
+ logger.blank();
60
+ },
61
+
47
62
  header(title) {
48
63
  logger.blank();
49
- console.log(chalk.bold.white(` ${title}`));
50
- console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
64
+ console.log(V2(" \u2502"));
65
+ console.log(V2(" \u251C\u2500 ") + chalk.bold.white(title));
66
+ console.log(V2(" \u2502"));
67
+ },
68
+
69
+ section(title) {
70
+ logger.blank();
71
+ console.log(V(" \u25C6 ") + chalk.bold.white(title));
72
+ console.log(DIM(" " + "\u2500".repeat(48)));
51
73
  },
52
74
 
53
75
  box(title, lines) {
54
- const w = 54;
55
- const border = chalk.dim("\u2500".repeat(w));
76
+ const w = 52;
56
77
  logger.blank();
57
- console.log(chalk.dim(` \u250c${border}\u2510`));
78
+ console.log(V2(" \u256D" + "\u2500".repeat(w) + "\u256E"));
58
79
  console.log(
59
- chalk.dim(" \u2502") +
60
- chalk.bold.white(` ${title.padEnd(w - 1)}`) +
61
- chalk.dim("\u2502"),
80
+ V2(" \u2502 ") + chalk.bold.white(title.padEnd(w - 2)) + V2(" \u2502"),
62
81
  );
63
- console.log(chalk.dim(` \u251c${border}\u2524`));
82
+ console.log(V2(" \u251C" + "\u2500".repeat(w) + "\u2524"));
64
83
  for (const line of lines) {
65
- const padded = ` ${line}`.padEnd(w);
66
- console.log(chalk.dim(" \u2502") + padded + chalk.dim(" \u2502"));
84
+ const content = ` ${line}`.padEnd(w - 1);
85
+ console.log(V2(" \u2502") + content + V2(" \u2502"));
67
86
  }
68
- console.log(chalk.dim(` \u2514${border}\u2518`));
87
+ console.log(V2(" \u2570" + "\u2500".repeat(w) + "\u256F"));
69
88
  logger.blank();
70
89
  },
71
90
 
@@ -75,30 +94,113 @@ export const logger = {
75
94
  Math.max(h.length, ...rows.map((r) => String(r[i] || "").length)) + 2,
76
95
  );
77
96
  const headerLine = headers
78
- .map((h, i) => chalk.bold(h.padEnd(colWidths[i])))
97
+ .map((h, i) => V(h.padEnd(colWidths[i])))
79
98
  .join("");
80
- const separator = colWidths.map((w) => "\u2500".repeat(w)).join("");
81
- console.log(chalk.dim(" ") + headerLine);
82
- console.log(chalk.dim(` ${separator}`));
99
+ const separator = colWidths.map((w) => DIM("\u2500".repeat(w))).join("");
100
+ logger.blank();
101
+ console.log(" " + headerLine);
102
+ console.log(" " + separator);
83
103
  for (const row of rows) {
84
104
  const line = row
85
- .map((cell, i) => String(cell || "").padEnd(colWidths[i]))
105
+ .map((cell, i) => {
106
+ const str = String(cell || "");
107
+ if (str.includes("\u2714")) return G(str.padEnd(colWidths[i]));
108
+ if (str.includes("\u2716")) return R(str.padEnd(colWidths[i]));
109
+ return W(str.padEnd(colWidths[i]));
110
+ })
86
111
  .join("");
87
- console.log(` ${line}`);
112
+ console.log(" " + line);
88
113
  }
89
114
  },
90
115
 
91
116
  testResult(name, status, duration) {
92
- const icon =
93
- status === "passed"
94
- ? chalk.green(ICONS.success)
95
- : status === "failed"
96
- ? chalk.red(ICONS.error)
97
- : chalk.yellow("\u25cb");
98
- const dur = duration ? chalk.dim(` (${formatMs(duration)})`) : "";
99
- const nameStr = status === "failed" ? chalk.red(name) : name;
100
- console.log(` ${icon} ${nameStr}${dur}`);
117
+ const icons = {
118
+ passed: G("\u2714"),
119
+ failed: R("\u2716"),
120
+ skipped: Y("\u25CB"),
121
+ running: V("\u25CF"),
122
+ };
123
+ const icon = icons[status] || DIM("\u25CB");
124
+ const dur = duration ? DIM(` ${formatMs(duration)}`) : "";
125
+ const nameStr =
126
+ status === "failed" ? R(name) : status === "skipped" ? Y(name) : W(name);
127
+ console.log(` ${icon} ${nameStr}${dur}`);
128
+ },
129
+
130
+ liveTest(index, total, name, status, duration) {
131
+ const pct = Math.round((index / total) * 100);
132
+ const bar = progressBar(index, total, 20);
133
+ const icons = {
134
+ passed: G("\u2714"),
135
+ failed: R("\u2716"),
136
+ skipped: Y("\u25CB"),
137
+ running: V("\u29BF"),
138
+ };
139
+ const icon = icons[status] || V("\u29BF");
140
+ const dur = duration ? DIM(` ${formatMs(duration)}`) : "";
141
+ const counter = DIM(`[${index}/${total}]`);
142
+
143
+ process.stdout.write(
144
+ `\r ${V(bar)} ${counter} ${icon} ${W(name)}${dur}${"".padEnd(20)}`,
145
+ );
146
+ if (status !== "running") process.stdout.write("\n");
147
+ },
148
+
149
+ progress(label, current, total) {
150
+ const bar = progressBar(current, total, 24);
151
+ const pct = total > 0 ? Math.round((current / total) * 100) : 0;
152
+ process.stdout.write(
153
+ `\r ${V(bar)} ${V2(`${pct}%`)} ${DIM(label)}${"".padEnd(10)}`,
154
+ );
155
+ },
156
+
157
+ summary(passed, failed, skipped, duration) {
158
+ logger.blank();
159
+ console.log(V2(" \u256D" + "\u2500".repeat(42) + "\u256E"));
160
+ console.log(
161
+ V2(" \u2502") + chalk.bold.white(" Results".padEnd(42)) + V2("\u2502"),
162
+ );
163
+ console.log(V2(" \u251C" + "\u2500".repeat(42) + "\u2524"));
164
+ console.log(
165
+ V2(" \u2502") +
166
+ G(` \u2714 ${String(passed).padStart(3)} passed`.padEnd(42)) +
167
+ V2("\u2502"),
168
+ );
169
+ if (failed > 0)
170
+ console.log(
171
+ V2(" \u2502") +
172
+ R(` \u2716 ${String(failed).padStart(3)} failed`.padEnd(42)) +
173
+ V2("\u2502"),
174
+ );
175
+ if (skipped > 0)
176
+ console.log(
177
+ V2(" \u2502") +
178
+ Y(` \u25CB ${String(skipped).padStart(3)} skipped`.padEnd(42)) +
179
+ V2("\u2502"),
180
+ );
181
+ console.log(
182
+ V2(" \u2502") +
183
+ DIM(` \u23F1 ${formatMs(duration)}`.padEnd(42)) +
184
+ V2("\u2502"),
185
+ );
186
+ const total = passed + failed + skipped;
187
+ const rate = total > 0 ? Math.round((passed / total) * 100) : 0;
188
+ const rateColor = rate >= 80 ? G : rate >= 50 ? Y : R;
189
+ console.log(
190
+ V2(" \u2502") +
191
+ rateColor(` ${rate}% pass rate`.padEnd(42)) +
192
+ V2("\u2502"),
193
+ );
194
+ console.log(V2(" \u2570" + "\u2500".repeat(42) + "\u256F"));
195
+ logger.blank();
101
196
  },
102
197
  };
103
198
 
199
+ function progressBar(current, total, width) {
200
+ const pct = total > 0 ? current / total : 0;
201
+ const filled = Math.round(pct * width);
202
+ const empty = width - filled;
203
+ return "\u2588".repeat(filled) + DIM("\u2591".repeat(empty));
204
+ }
205
+
104
206
  export { formatMs };
@@ -1,6 +1,6 @@
1
1
  export function buildE2EPrompt(featureName, context) {
2
2
  const sourceSection = context.sourceCode
3
- ? `\n## Source Code (for understanding page structure)\n\`\`\`\n${context.sourceCode.slice(0, 6000)}\n\`\`\`\n`
3
+ ? `\n## Source Code Analysis\nUse this to understand the page structure, components, routes, buttons, forms, modals, and data flow:\n\`\`\`\n${context.sourceCode.slice(0, 8000)}\n\`\`\`\n`
4
4
  : "";
5
5
 
6
6
  const routeSection = context.route
@@ -8,59 +8,60 @@ export function buildE2EPrompt(featureName, context) {
8
8
  : "";
9
9
 
10
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`
11
+ ? `\n## QA Use Cases (from QA team)\n${context.useCases.map((uc) => `### ${uc.scenario}\n${uc.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}`).join("\n\n")}\n`
12
12
  : "";
13
13
 
14
- return `You are a senior QA automation engineer writing Playwright E2E tests.
14
+ return `You are an expert QA automation engineer. Write comprehensive Playwright E2E tests.
15
15
 
16
16
  ## Feature: ${featureName}
17
17
  ## Base URL: ${context.baseUrl || "http://localhost:3000"}
18
- ## Auth Provider: ${context.authProvider || "none"}
18
+ ## Auth: ${context.authProvider || "none"} (use auth helper if needed)
19
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
20
 
28
- ## Playwright Rules
21
+ ## REQUIREMENTS — Generate AT LEAST 8 test cases covering:
22
+
23
+ ### Must Include (P0 — Critical):
24
+ 1. Page Load & Layout — verify page loads, key sections visible, no console errors
25
+ 2. Navigation — verify URL is correct, breadcrumbs/tabs work
26
+ 3. Primary User Action — the main thing a user does on this page (click button, submit form, etc)
27
+ 4. Data Display — verify data renders correctly (lists, cards, tables, amounts)
28
+
29
+ ### Should Include (P1 — Important):
30
+ 5. Secondary Actions — other clickable elements, links, toggles
31
+ 6. Error States — what happens when something goes wrong (empty data, network error)
32
+ 7. Loading States — skeleton/spinner shows while data loads
33
+ 8. Responsive/Mobile — if applicable, test viewport changes
34
+
35
+ ### Nice to Have (P2 — Edge Cases):
36
+ 9. Edge Cases — empty states, boundary values, special characters
37
+ 10. Authentication Guards — verify redirects when not logged in
38
+
39
+ ## PLAYWRIGHT RULES:
29
40
  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:
41
+ 2. ALWAYS start with \`test.describe("${featureName}", () => { ... })\`
42
+ 3. Use \`test.beforeEach\` for common setup (login + navigate)
43
+ 4. Use accessible selectors IN THIS ORDER of preference:
44
+ - \`page.getByRole("button", { name: /text/i })\`
45
+ - \`page.getByText(/text/i)\`
46
+ - \`page.getByTestId("id")\`
47
+ - \`page.locator("css-selector")\` (LAST resort)
48
+ 5. After every navigation: \`await page.waitForLoadState("networkidle")\`
49
+ 6. Take screenshot at EVERY test: \`await page.screenshot({ path: "e2e/screenshots/${featureName}-{testname}.png", fullPage: true })\`
50
+ 7. Use \`expect(locator).toBeVisible()\` not \`toBeTruthy()\`
51
+ 8. Use \`{ timeout: 15000 }\` for slow-loading elements
52
+ 9. Handle auth:
37
53
  \`\`\`
38
54
  const { login } = require("../helpers/auth.js");
39
- test.beforeEach(async ({ page, baseURL }) => { await login(page, baseURL); });
55
+ test.beforeEach(async ({ page, baseURL }) => {
56
+ await login(page, baseURL);
57
+ await page.goto("${context.route || "/" + featureName}");
58
+ await page.waitForLoadState("networkidle");
59
+ });
40
60
  \`\`\`
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
- \`\`\`
61
+ 10. Each test MUST have a clear assertion with \`expect()\`
62
+ 11. Add \`test.slow()\` for tests that need extra time
64
63
 
65
- Return ONLY the JavaScript code. No markdown fences. No explanation.`;
64
+ ## OUTPUT FORMAT:
65
+ Return ONLY JavaScript code. No markdown fences. No explanation.
66
+ Generate a COMPLETE runnable spec file with 8-12 tests.`;
66
67
  }