@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 +82 -13
- package/package.json +1 -1
- package/src/cli/commands/test.js +229 -230
- package/src/cli/interactive.js +211 -0
- package/src/core/constants.js +1 -1
- package/src/core/logger.js +147 -45
- package/src/e2e/e2e-prompts.js +45 -44
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
|
|
14
|
+
const hasSubcommand =
|
|
15
|
+
process.argv.length > 2 && !process.argv[2].startsWith("-");
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
if (!hasSubcommand) {
|
|
18
|
+
runInteractive(process.cwd())
|
|
19
|
+
.then(async (result) => {
|
|
20
|
+
if (!result) process.exit(0);
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
package/src/cli/commands/test.js
CHANGED
|
@@ -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
|
|
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("
|
|
66
|
+
logger.header("E2E Automation Test");
|
|
58
67
|
logger.blank();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
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(`
|
|
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
|
|
117
|
-
|
|
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
|
|
167
|
+
G(` E2E spec: ${chalk.underline(path.relative(projectDir, specFile))}`),
|
|
167
168
|
);
|
|
168
169
|
} catch (err) {
|
|
169
|
-
spinner2.fail(`
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
180
|
+
const result = await runPlaywrightStreaming(
|
|
181
|
+
projectDir,
|
|
182
|
+
specFile,
|
|
183
|
+
options,
|
|
184
|
+
baseUrl,
|
|
185
|
+
);
|
|
189
186
|
|
|
190
|
-
if (exitCode === 0) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
logger.summary(
|
|
209
|
+
result.passed,
|
|
210
|
+
result.failed,
|
|
211
|
+
result.skipped,
|
|
212
|
+
result.duration,
|
|
209
213
|
);
|
|
210
|
-
logger.
|
|
211
|
-
logger.dim(`
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
239
|
+
fixSpinner.succeed(G(" Fix applied"));
|
|
238
240
|
} catch (err) {
|
|
239
|
-
|
|
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.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
stdout
|
|
295
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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(
|
|
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:
|
|
370
|
-
totalPassed:
|
|
371
|
-
totalFailed:
|
|
372
|
-
totalSkipped:
|
|
373
|
-
overallPassRate:
|
|
374
|
-
|
|
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:
|
|
459
|
+
duration: result.duration,
|
|
428
460
|
});
|
|
429
461
|
|
|
430
|
-
logger.blank();
|
|
431
462
|
logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
|
|
432
463
|
} catch {
|
|
433
|
-
/*
|
|
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
|
+
}
|
package/src/core/constants.js
CHANGED
package/src/core/logger.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
20
|
+
console.log(V(` \u25CF `) + W(msg));
|
|
21
21
|
},
|
|
22
22
|
success(msg) {
|
|
23
|
-
console.log(
|
|
23
|
+
console.log(G(` \u2714 `) + W(msg));
|
|
24
24
|
},
|
|
25
25
|
warn(msg) {
|
|
26
|
-
console.log(
|
|
26
|
+
console.log(Y(` \u25B2 `) + Y(msg));
|
|
27
27
|
},
|
|
28
28
|
error(msg) {
|
|
29
|
-
console.log(
|
|
29
|
+
console.log(R(` \u2716 `) + R(msg));
|
|
30
30
|
},
|
|
31
31
|
|
|
32
32
|
step(current, total, msg) {
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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(
|
|
50
|
-
console.log(
|
|
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 =
|
|
55
|
-
const border = chalk.dim("\u2500".repeat(w));
|
|
76
|
+
const w = 52;
|
|
56
77
|
logger.blank();
|
|
57
|
-
console.log(
|
|
78
|
+
console.log(V2(" \u256D" + "\u2500".repeat(w) + "\u256E"));
|
|
58
79
|
console.log(
|
|
59
|
-
|
|
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(
|
|
82
|
+
console.log(V2(" \u251C" + "\u2500".repeat(w) + "\u2524"));
|
|
64
83
|
for (const line of lines) {
|
|
65
|
-
const
|
|
66
|
-
console.log(
|
|
84
|
+
const content = ` ${line}`.padEnd(w - 1);
|
|
85
|
+
console.log(V2(" \u2502") + content + V2(" \u2502"));
|
|
67
86
|
}
|
|
68
|
-
console.log(
|
|
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) =>
|
|
97
|
+
.map((h, i) => V(h.padEnd(colWidths[i])))
|
|
79
98
|
.join("");
|
|
80
|
-
const separator = colWidths.map((w) => "\u2500".repeat(w)).join("");
|
|
81
|
-
|
|
82
|
-
console.log(
|
|
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) =>
|
|
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(
|
|
112
|
+
console.log(" " + line);
|
|
88
113
|
}
|
|
89
114
|
},
|
|
90
115
|
|
|
91
116
|
testResult(name, status, duration) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
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 };
|
package/src/e2e/e2e-prompts.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function buildE2EPrompt(featureName, context) {
|
|
2
2
|
const sourceSection = context.sourceCode
|
|
3
|
-
? `\n## Source Code
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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.
|
|
31
|
-
3. Use
|
|
32
|
-
4. Use
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 }) => {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
}
|