@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.
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/bin/qabot.js +82 -13
- package/package.json +10 -2
- package/src/cli/commands/test.js +231 -231
- 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/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) {
|
|
@@ -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("
|
|
66
|
+
logger.header("E2E Automation Test");
|
|
57
67
|
logger.blank();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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(`
|
|
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
|
|
116
|
-
|
|
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
|
|
167
|
+
G(` E2E spec: ${chalk.underline(path.relative(projectDir, specFile))}`),
|
|
166
168
|
);
|
|
167
169
|
} catch (err) {
|
|
168
|
-
spinner2.fail(`
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
const result = await runPlaywrightStreaming(
|
|
181
|
+
projectDir,
|
|
182
|
+
specFile,
|
|
183
|
+
options,
|
|
184
|
+
baseUrl,
|
|
185
|
+
);
|
|
188
186
|
|
|
189
|
-
if (exitCode === 0) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
logger.summary(
|
|
209
|
+
result.passed,
|
|
210
|
+
result.failed,
|
|
211
|
+
result.skipped,
|
|
212
|
+
result.duration,
|
|
208
213
|
);
|
|
209
|
-
logger.
|
|
210
|
-
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`);
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
239
|
+
fixSpinner.succeed(G(" Fix applied"));
|
|
237
240
|
} catch (err) {
|
|
238
|
-
|
|
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.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
stdout
|
|
294
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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(
|
|
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:
|
|
369
|
-
totalPassed:
|
|
370
|
-
totalFailed:
|
|
371
|
-
totalSkipped:
|
|
372
|
-
overallPassRate:
|
|
373
|
-
|
|
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:
|
|
459
|
+
duration: result.duration,
|
|
427
460
|
});
|
|
428
461
|
|
|
429
|
-
logger.blank();
|
|
430
462
|
logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
|
|
431
463
|
} catch {
|
|
432
|
-
/*
|
|
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
|
}
|