@nhonh/qabot 0.6.0 → 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.
@@ -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) {
@@ -33,6 +42,7 @@ export function registerTestCommand(program) {
33
42
  .option("--headed", "Run browser in headed mode (visible)")
34
43
  .option("--no-fix", "Skip auto-fix on failure")
35
44
  .option("--skip-gen", "Skip test generation, run existing specs only")
45
+ .option("-u, --url <url>", "Target URL (overrides environment config)")
36
46
  .option("--use-cases <dir>", "Directory containing use case documents")
37
47
  .option("--model <model>", "AI model to use")
38
48
  .option("-d, --dir <path>", "Project directory", process.cwd())
@@ -51,28 +61,27 @@ async function runTest(feature, options) {
51
61
  const profile = await analyzeProject(projectDir);
52
62
  const envConfig =
53
63
  config.environments?.[options.env] || config.environments?.default;
54
- const baseUrl = envConfig?.url || "http://localhost:3000";
64
+ const baseUrl = options.url || envConfig?.url || "http://localhost:3000";
55
65
 
56
- logger.header("QABot \u2014 E2E Automation Test");
66
+ logger.header("E2E Automation Test");
57
67
  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
- );
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"));
64
71
  logger.blank();
65
- console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
66
72
 
67
- const spinner = ora("Setting up Playwright...").start();
73
+ const spinner = ora({
74
+ text: V(" Setting up Playwright..."),
75
+ spinner: "dots",
76
+ }).start();
68
77
  try {
69
78
  await ensurePlaywright(projectDir);
70
79
  await ensureE2EStructure(projectDir);
71
80
  await writePlaywrightConfig(projectDir, config);
72
81
  await writeAuthHelper(projectDir, config);
73
- spinner.succeed("Playwright ready");
82
+ spinner.succeed(G(" Playwright ready"));
74
83
  } catch (err) {
75
- spinner.fail(`Playwright setup failed: ${err.message}`);
84
+ spinner.fail(R(` Setup failed: ${err.message}`));
76
85
  return;
77
86
  }
78
87
 
@@ -83,22 +92,15 @@ async function runTest(feature, options) {
83
92
  if (options.skipGen) {
84
93
  const existing = await findFiles(specDir, "*.spec.js");
85
94
  if (existing.length === 0) {
86
- logger.error(
87
- "No existing E2E specs found. Run without --skip-gen to generate.",
88
- );
95
+ logger.error("No existing E2E specs found. Run without --skip-gen.");
89
96
  return;
90
97
  }
91
98
  specFile = feature
92
99
  ? existing.find((f) => f.toLowerCase().includes(feature.toLowerCase()))
93
100
  : null;
94
- logger.info(
95
- `Running ${specFile ? path.basename(specFile) : "all"} existing specs`,
96
- );
97
101
  } else {
98
102
  if (!feature) {
99
- logger.error(
100
- "Feature name required for test generation. Usage: qabot test <feature>",
101
- );
103
+ logger.error("Feature name required. Usage: qabot test <feature>");
102
104
  return;
103
105
  }
104
106
 
@@ -111,9 +113,10 @@ async function runTest(feature, options) {
111
113
  return;
112
114
  }
113
115
 
114
- const spinner2 = ora(
115
- "AI is analyzing feature and generating E2E tests...",
116
- ).start();
116
+ const spinner2 = ora({
117
+ text: V(" AI analyzing feature..."),
118
+ spinner: "dots",
119
+ }).start();
117
120
 
118
121
  let sourceCode = "";
119
122
  if (featureConfig?.src) {
@@ -158,183 +161,225 @@ async function runTest(feature, options) {
158
161
  authProvider: config.auth?.provider || "none",
159
162
  useCases,
160
163
  });
161
-
162
164
  specFile = path.join(specDir, `${feature}.spec.js`);
163
165
  await writeFile(specFile, spec, "utf-8");
164
166
  spinner2.succeed(
165
- `E2E spec generated: ${chalk.underline(path.relative(projectDir, specFile))}`,
167
+ G(` E2E spec: ${chalk.underline(path.relative(projectDir, specFile))}`),
166
168
  );
167
169
  } catch (err) {
168
- spinner2.fail(`E2E generation failed: ${err.message}`);
170
+ spinner2.fail(R(` Generation failed: ${err.message}`));
169
171
  return;
170
172
  }
171
173
 
172
174
  if (options.fix !== false) {
173
- const aiForFix = ai;
174
-
175
175
  for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
176
176
  logger.blank();
177
- logger.step(
178
- attempt,
179
- MAX_FIX_ATTEMPTS,
180
- `Running E2E tests (attempt ${attempt})...`,
181
- );
177
+ logger.step(attempt, MAX_FIX_ATTEMPTS, `Attempt ${attempt}`);
178
+ logger.blank();
182
179
 
183
- const {
184
- exitCode,
185
- error: testError,
186
- stdout,
187
- } = runPlaywright(projectDir, specFile, options);
180
+ const result = await runPlaywrightStreaming(
181
+ projectDir,
182
+ specFile,
183
+ options,
184
+ baseUrl,
185
+ );
188
186
 
189
- if (exitCode === 0) {
190
- printPlaywrightSummary(stdout);
191
- logger.blank();
192
- 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}`);
193
196
  await generateE2EReport(
194
197
  projectDir,
195
198
  config,
196
199
  feature,
197
200
  options.env,
198
201
  baseUrl,
202
+ result,
199
203
  );
200
204
  return;
201
205
  }
202
206
 
203
207
  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
+ logger.summary(
209
+ result.passed,
210
+ result.failed,
211
+ result.skipped,
212
+ result.duration,
208
213
  );
209
- logger.dim(` Review: cat ${path.relative(projectDir, specFile)}`);
210
- 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`);
211
217
  await generateE2EReport(
212
218
  projectDir,
213
219
  config,
214
220
  feature,
215
221
  options.env,
216
222
  baseUrl,
223
+ result,
217
224
  );
218
225
  return;
219
226
  }
220
227
 
221
- logger.dim(` ${extractFirstError(testError || stdout)}`);
222
- logger.step(attempt, MAX_FIX_ATTEMPTS, "AI is fixing E2E test...");
223
-
228
+ const fixSpinner = ora({
229
+ text: V(" AI fixing errors..."),
230
+ spinner: "dots",
231
+ }).start();
224
232
  try {
225
233
  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
- );
234
+ const fixedCode = await fixE2ESpec(ai, currentCode, result.rawError, {
235
+ baseUrl,
236
+ authProvider: config.auth?.provider,
237
+ });
235
238
  await writeFile(specFile, fixedCode, "utf-8");
236
- logger.dim(" Fix applied. Re-running...");
239
+ fixSpinner.succeed(G(" Fix applied"));
237
240
  } catch (err) {
238
- logger.warn(` AI fix failed: ${err.message}`);
241
+ fixSpinner.fail(R(` Fix failed: ${err.message}`));
239
242
  break;
240
243
  }
241
244
  }
245
+ return;
242
246
  }
243
247
  }
244
248
 
245
249
  if (!specFile && !options.skipGen) return;
246
250
 
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);
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);
256
259
  await generateE2EReport(
257
260
  projectDir,
258
261
  config,
259
262
  feature || "all",
260
263
  options.env,
261
264
  baseUrl,
265
+ result,
262
266
  );
263
-
264
- if (exitCode !== 0) process.exit(1);
267
+ if (result.failed > 0) process.exit(1);
265
268
  }
266
269
 
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
- };
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");
281
282
 
282
- try {
283
- 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, {
284
299
  cwd: projectDir,
285
- stdio: "pipe",
286
- timeout: 120000,
287
300
  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
- }
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
+ });
297
362
  }
298
363
 
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
- }
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;
321
374
  }
322
375
 
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
- );
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;
338
383
  }
339
384
 
340
385
  function guessRoute(featureName) {
@@ -355,112 +400,67 @@ function guessRoute(featureName) {
355
400
  return routes[featureName] || `/${featureName}`;
356
401
  }
357
402
 
358
- 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
+ ) {
359
411
  try {
360
- const screenshotsDir = path.join(projectDir, "e2e", "screenshots");
361
- const screenshots = await findFiles(screenshotsDir, "*.png").catch(
362
- () => [],
363
- );
364
-
365
412
  const reporter = new ReportGenerator(config);
413
+ const tests = result.tests || [];
366
414
  const results = {
367
415
  summary: {
368
- totalTests: 0,
369
- totalPassed: 0,
370
- totalFailed: 0,
371
- totalSkipped: 0,
372
- overallPassRate: 0,
373
- 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
+ },
374
437
  },
375
- 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
+ ],
376
452
  };
377
453
 
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
454
  const reportPaths = await reporter.generate(results, {
422
455
  feature,
423
456
  environment: env,
424
457
  projectName: config.project?.name || "unknown",
425
458
  timestamp: new Date().toISOString(),
426
- duration: 0,
459
+ duration: result.duration,
427
460
  });
428
461
 
429
- logger.blank();
430
462
  logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
431
463
  } 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);
464
+ /* best-effort */
465
465
  }
466
466
  }