@nhonh/qabot 0.1.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.
@@ -0,0 +1,70 @@
1
+ export const VERSION = "0.1.0";
2
+ export const TOOL_NAME = "qabot";
3
+
4
+ export const PROJECT_TYPES = [
5
+ "react-spa",
6
+ "nextjs",
7
+ "vue",
8
+ "angular",
9
+ "dotnet",
10
+ "python",
11
+ "node",
12
+ "unknown",
13
+ ];
14
+ export const RUNNERS = [
15
+ "jest",
16
+ "vitest",
17
+ "playwright",
18
+ "cypress",
19
+ "pytest",
20
+ "xunit",
21
+ "dotnet-test",
22
+ ];
23
+ export const AI_PROVIDERS = ["openai", "anthropic", "ollama", "none"];
24
+ export const LAYERS = ["unit", "integration", "e2e"];
25
+ export const PRIORITIES = ["P0", "P1", "P2", "P3"];
26
+
27
+ export const TEST_STATUS = {
28
+ PASSED: "passed",
29
+ FAILED: "failed",
30
+ SKIPPED: "skipped",
31
+ PENDING: "pending",
32
+ RUNNING: "running",
33
+ };
34
+
35
+ export const DEFAULT_CONFIG = {
36
+ reporting: {
37
+ outputDir: "./qabot-reports",
38
+ openAfterRun: true,
39
+ history: true,
40
+ formats: ["html", "json"],
41
+ },
42
+ ai: { provider: "none", model: "gpt-4o", apiKeyEnv: "OPENAI_API_KEY" },
43
+ useCases: { dir: "./docs/use-cases", formats: ["md", "feature", "txt"] },
44
+ };
45
+
46
+ export const FRAMEWORK_DETECT_MAP = {
47
+ react: { deps: ["react", "react-dom"], type: "react-spa" },
48
+ next: { deps: ["next"], type: "nextjs" },
49
+ vue: { deps: ["vue"], type: "vue" },
50
+ angular: { deps: ["@angular/core"], type: "angular" },
51
+ };
52
+
53
+ export const RUNNER_DETECT_MAP = {
54
+ jest: {
55
+ devDeps: ["jest", "jest-cli", "@jest/core"],
56
+ configs: ["jest.config.js", "jest.config.ts", "jest.config.mjs"],
57
+ },
58
+ vitest: {
59
+ devDeps: ["vitest"],
60
+ configs: ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
61
+ },
62
+ playwright: {
63
+ devDeps: ["@playwright/test", "playwright"],
64
+ configs: ["playwright.config.js", "playwright.config.ts"],
65
+ },
66
+ cypress: {
67
+ devDeps: ["cypress"],
68
+ configs: ["cypress.config.js", "cypress.config.ts"],
69
+ },
70
+ };
@@ -0,0 +1,104 @@
1
+ import chalk from "chalk";
2
+
3
+ const ICONS = {
4
+ info: "\u2139",
5
+ success: "\u2713",
6
+ warn: "\u26a0",
7
+ error: "\u2717",
8
+ step: "\u25b8",
9
+ bullet: "\u2022",
10
+ };
11
+
12
+ function formatMs(ms) {
13
+ if (ms < 1000) return `${ms}ms`;
14
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
15
+ return `${(ms / 60000).toFixed(1)}m`;
16
+ }
17
+
18
+ export const logger = {
19
+ info(msg) {
20
+ console.log(chalk.blue(` ${ICONS.info} ${msg}`));
21
+ },
22
+ success(msg) {
23
+ console.log(chalk.green(` ${ICONS.success} ${msg}`));
24
+ },
25
+ warn(msg) {
26
+ console.log(chalk.yellow(` ${ICONS.warn} ${msg}`));
27
+ },
28
+ error(msg) {
29
+ console.log(chalk.red(` ${ICONS.error} ${msg}`));
30
+ },
31
+
32
+ step(current, total, msg) {
33
+ console.log(
34
+ chalk.cyan(
35
+ ` ${ICONS.step} ${chalk.dim(`[${current}/${total}]`)} ${msg}`,
36
+ ),
37
+ );
38
+ },
39
+
40
+ dim(msg) {
41
+ console.log(chalk.dim(` ${msg}`));
42
+ },
43
+ blank() {
44
+ console.log("");
45
+ },
46
+
47
+ header(title) {
48
+ logger.blank();
49
+ console.log(chalk.bold.white(` ${title}`));
50
+ console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
51
+ },
52
+
53
+ box(title, lines) {
54
+ const w = 54;
55
+ const border = chalk.dim("\u2500".repeat(w));
56
+ logger.blank();
57
+ console.log(chalk.dim(` \u250c${border}\u2510`));
58
+ console.log(
59
+ chalk.dim(" \u2502") +
60
+ chalk.bold.white(` ${title.padEnd(w - 1)}`) +
61
+ chalk.dim("\u2502"),
62
+ );
63
+ console.log(chalk.dim(` \u251c${border}\u2524`));
64
+ for (const line of lines) {
65
+ const padded = ` ${line}`.padEnd(w);
66
+ console.log(chalk.dim(" \u2502") + padded + chalk.dim(" \u2502"));
67
+ }
68
+ console.log(chalk.dim(` \u2514${border}\u2518`));
69
+ logger.blank();
70
+ },
71
+
72
+ table(headers, rows) {
73
+ const colWidths = headers.map(
74
+ (h, i) =>
75
+ Math.max(h.length, ...rows.map((r) => String(r[i] || "").length)) + 2,
76
+ );
77
+ const headerLine = headers
78
+ .map((h, i) => chalk.bold(h.padEnd(colWidths[i])))
79
+ .join("");
80
+ const separator = colWidths.map((w) => "\u2500".repeat(w)).join("");
81
+ console.log(chalk.dim(" ") + headerLine);
82
+ console.log(chalk.dim(` ${separator}`));
83
+ for (const row of rows) {
84
+ const line = row
85
+ .map((cell, i) => String(cell || "").padEnd(colWidths[i]))
86
+ .join("");
87
+ console.log(` ${line}`);
88
+ }
89
+ },
90
+
91
+ 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}`);
101
+ },
102
+ };
103
+
104
+ export { formatMs };
@@ -0,0 +1,82 @@
1
+ import { spawn } from "node:child_process";
2
+ import treeKill from "tree-kill";
3
+
4
+ export class ProcessManager {
5
+ async run(command, options = {}) {
6
+ const {
7
+ cwd = process.cwd(),
8
+ env = {},
9
+ timeout = 120000,
10
+ onStdout,
11
+ onStderr,
12
+ } = options;
13
+
14
+ return new Promise((resolve) => {
15
+ const startTime = Date.now();
16
+ let stdout = "";
17
+ let stderr = "";
18
+ let killed = false;
19
+
20
+ const mergedEnv = { ...process.env, ...env, FORCE_COLOR: "0" };
21
+ const isWindows = process.platform === "win32";
22
+
23
+ const child = spawn(command, {
24
+ cwd,
25
+ env: mergedEnv,
26
+ shell: true,
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ windowsHide: true,
29
+ });
30
+
31
+ const timer =
32
+ timeout > 0
33
+ ? setTimeout(() => {
34
+ killed = true;
35
+ treeKill(child.pid, "SIGTERM");
36
+ }, timeout)
37
+ : null;
38
+
39
+ child.stdout.on("data", (data) => {
40
+ const text = data.toString();
41
+ stdout += text;
42
+ if (onStdout) {
43
+ for (const line of text.split("\n").filter(Boolean)) {
44
+ onStdout(line);
45
+ }
46
+ }
47
+ });
48
+
49
+ child.stderr.on("data", (data) => {
50
+ const text = data.toString();
51
+ stderr += text;
52
+ if (onStderr) {
53
+ for (const line of text.split("\n").filter(Boolean)) {
54
+ onStderr(line);
55
+ }
56
+ }
57
+ });
58
+
59
+ child.on("close", (exitCode) => {
60
+ if (timer) clearTimeout(timer);
61
+ resolve({
62
+ stdout,
63
+ stderr,
64
+ exitCode: exitCode ?? 1,
65
+ duration: Date.now() - startTime,
66
+ killed,
67
+ });
68
+ });
69
+
70
+ child.on("error", (err) => {
71
+ if (timer) clearTimeout(timer);
72
+ resolve({
73
+ stdout,
74
+ stderr: stderr + "\n" + err.message,
75
+ exitCode: 1,
76
+ duration: Date.now() - startTime,
77
+ killed: false,
78
+ });
79
+ });
80
+ });
81
+ }
82
+ }
@@ -0,0 +1,81 @@
1
+ export class TestResult {
2
+ constructor(runner, layer, feature) {
3
+ this.runner = runner;
4
+ this.layer = layer;
5
+ this.feature = feature;
6
+ this.timestamp = new Date().toISOString();
7
+ this.duration = 0;
8
+ this.summary = { total: 0, passed: 0, failed: 0, skipped: 0, passRate: 0 };
9
+ this.tests = [];
10
+ this.coverage = null;
11
+ this.artifacts = [];
12
+ this.errors = [];
13
+ this.rawOutput = "";
14
+ }
15
+ }
16
+
17
+ export class ResultCollector {
18
+ constructor() {
19
+ this.results = [];
20
+ }
21
+
22
+ addResult(testResult) {
23
+ this.results.push(testResult);
24
+ }
25
+
26
+ getSummary() {
27
+ const byLayer = {};
28
+ const byFeature = {};
29
+ let totalTests = 0,
30
+ totalPassed = 0,
31
+ totalFailed = 0,
32
+ totalSkipped = 0,
33
+ totalDuration = 0;
34
+
35
+ for (const r of this.results) {
36
+ totalTests += r.summary.total;
37
+ totalPassed += r.summary.passed;
38
+ totalFailed += r.summary.failed;
39
+ totalSkipped += r.summary.skipped;
40
+ totalDuration += r.duration;
41
+
42
+ if (!byLayer[r.layer])
43
+ byLayer[r.layer] = { total: 0, passed: 0, failed: 0, skipped: 0 };
44
+ byLayer[r.layer].total += r.summary.total;
45
+ byLayer[r.layer].passed += r.summary.passed;
46
+ byLayer[r.layer].failed += r.summary.failed;
47
+ byLayer[r.layer].skipped += r.summary.skipped;
48
+
49
+ if (!byFeature[r.feature])
50
+ byFeature[r.feature] = { total: 0, passed: 0, failed: 0, skipped: 0 };
51
+ byFeature[r.feature].total += r.summary.total;
52
+ byFeature[r.feature].passed += r.summary.passed;
53
+ byFeature[r.feature].failed += r.summary.failed;
54
+ byFeature[r.feature].skipped += r.summary.skipped;
55
+ }
56
+
57
+ return {
58
+ totalTests,
59
+ totalPassed,
60
+ totalFailed,
61
+ totalSkipped,
62
+ overallPassRate:
63
+ totalTests > 0 ? Math.round((totalPassed / totalTests) * 100) : 0,
64
+ totalDuration,
65
+ byLayer,
66
+ byFeature,
67
+ };
68
+ }
69
+
70
+ getAllTests() {
71
+ return this.results.flatMap((r) => r.tests);
72
+ }
73
+
74
+ getFailedTests() {
75
+ return this.getAllTests().filter((t) => t.status === "failed");
76
+ }
77
+
78
+ toJSON() {
79
+ return { results: this.results, summary: this.getSummary() };
80
+ }
81
+ }
@@ -0,0 +1,124 @@
1
+ import { ProcessManager } from "./process-manager.js";
2
+ import { ResultCollector, TestResult } from "./result-collector.js";
3
+ import { getRunner } from "../runners/runner-registry.js";
4
+ import { LAYERS } from "../core/constants.js";
5
+
6
+ export class TestExecutor {
7
+ constructor(config, projectProfile) {
8
+ this.config = config;
9
+ this.profile = projectProfile;
10
+ this.processManager = new ProcessManager();
11
+ this.resultCollector = new ResultCollector();
12
+ this.listeners = [];
13
+ }
14
+
15
+ onProgress(callback) {
16
+ this.listeners.push(callback);
17
+ }
18
+
19
+ async execute(options = {}) {
20
+ const {
21
+ feature = "all",
22
+ layers = null,
23
+ env = "local",
24
+ coverage = false,
25
+ verbose = false,
26
+ timeout = 120000,
27
+ } = options;
28
+ const targetLayers = layers || Object.keys(this.config.layers || {});
29
+
30
+ this.emit({ type: "start", feature, layers: targetLayers, env });
31
+
32
+ for (const layerName of targetLayers) {
33
+ const layerConfig = this.config.layers?.[layerName];
34
+ if (!layerConfig) {
35
+ this.emit({
36
+ type: "layer-skip",
37
+ layer: layerName,
38
+ reason: "not configured",
39
+ });
40
+ continue;
41
+ }
42
+
43
+ const runner = getRunner(layerConfig.runner, layerConfig);
44
+ this.emit({
45
+ type: "layer-start",
46
+ layer: layerName,
47
+ runner: runner.getDisplayName(),
48
+ });
49
+
50
+ const command = runner.buildCommand({
51
+ pattern: feature !== "all" ? feature : "",
52
+ coverage,
53
+ verbose,
54
+ env: this.config.environments?.[env],
55
+ });
56
+
57
+ const result = await this.processManager.run(command, {
58
+ cwd: this.profile.paths.root,
59
+ timeout,
60
+ onStdout: (line) =>
61
+ this.emit({ type: "stdout", layer: layerName, line }),
62
+ onStderr: (line) =>
63
+ this.emit({ type: "stderr", layer: layerName, line }),
64
+ });
65
+
66
+ const parsed = runner.parseOutput(
67
+ result.stdout,
68
+ result.stderr,
69
+ result.exitCode,
70
+ );
71
+
72
+ const testResult = new TestResult(runner.name, layerName, feature);
73
+ testResult.duration = result.duration;
74
+ testResult.summary = parsed.summary;
75
+ testResult.tests = parsed.tests;
76
+ testResult.coverage = parsed.coverage;
77
+ testResult.rawOutput = verbose ? result.stdout : "";
78
+
79
+ if (result.killed) {
80
+ testResult.errors.push({
81
+ message: `Timed out after ${timeout}ms`,
82
+ type: "timeout",
83
+ });
84
+ }
85
+
86
+ this.resultCollector.addResult(testResult);
87
+
88
+ for (const test of parsed.tests) {
89
+ this.emit({
90
+ type:
91
+ test.status === "passed"
92
+ ? "test-pass"
93
+ : test.status === "failed"
94
+ ? "test-fail"
95
+ : "test-skip",
96
+ layer: layerName,
97
+ test,
98
+ });
99
+ }
100
+
101
+ this.emit({
102
+ type: "layer-end",
103
+ layer: layerName,
104
+ summary: parsed.summary,
105
+ duration: result.duration,
106
+ });
107
+ }
108
+
109
+ const summary = this.resultCollector.getSummary();
110
+ this.emit({ type: "complete", summary });
111
+
112
+ return this.resultCollector;
113
+ }
114
+
115
+ emit(event) {
116
+ for (const cb of this.listeners) {
117
+ try {
118
+ cb(event);
119
+ } catch {
120
+ /* listener error should not break execution */
121
+ }
122
+ }
123
+ }
124
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { loadConfig, writeConfig, validateConfig } from "./core/config.js";
2
+ export { logger } from "./core/logger.js";
3
+ export { analyzeProject } from "./analyzers/project-analyzer.js";
4
+ export { detectTests } from "./analyzers/test-detector.js";
5
+ export { detectFeatures } from "./analyzers/feature-detector.js";
6
+ export { detectEnvironments } from "./analyzers/env-detector.js";
7
+ export { TestExecutor } from "./executor/test-executor.js";
8
+ export { ResultCollector, TestResult } from "./executor/result-collector.js";
9
+ export { ReportGenerator } from "./reporter/report-generator.js";
10
+ export { AIEngine } from "./ai/ai-engine.js";
11
+ export { getRunner, listRunners } from "./runners/runner-registry.js";
12
+ export { VERSION, TOOL_NAME } from "./core/constants.js";
@@ -0,0 +1,149 @@
1
+ import { formatMs } from "../core/logger.js";
2
+
3
+ export class HtmlBuilder {
4
+ render(results, meta) {
5
+ const summary = results.summary || {};
6
+ const allTests = (results.results || []).flatMap((r) => r.tests || []);
7
+ const failedTests = allTests.filter((t) => t.status === "failed");
8
+ const passedTests = allTests.filter((t) => t.status === "passed");
9
+ const skippedTests = allTests.filter((t) => t.status === "skipped");
10
+
11
+ return `<!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
+ <title>QABot Report - ${esc(meta.feature || "all")} | ${esc(meta.projectName || "")}</title>
17
+ <style>
18
+ *{margin:0;padding:0;box-sizing:border-box}
19
+ :root{--bg:#0f172a;--card:#1e293b;--card-hover:#273548;--border:#334155;--text:#e2e8f0;--text-dim:#94a3b8;--accent:#3b82f6;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--font:system-ui,-apple-system,sans-serif;--mono:'SF Mono',SFMono-Regular,Menlo,monospace}
20
+ body{background:var(--bg);color:var(--text);font-family:var(--font);line-height:1.6;padding:2rem}
21
+ .container{max-width:1200px;margin:0 auto}
22
+ h1{font-size:1.8rem;font-weight:700;margin-bottom:.5rem}
23
+ h2{font-size:1.3rem;font-weight:600;margin:2rem 0 1rem;color:var(--accent)}
24
+ .subtitle{color:var(--text-dim);font-size:.9rem;margin-bottom:2rem}
25
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
26
+ .card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;text-align:center}
27
+ .card .value{font-size:2rem;font-weight:700;margin:.5rem 0}
28
+ .card .label{color:var(--text-dim);font-size:.85rem;text-transform:uppercase;letter-spacing:.05em}
29
+ .card.pass .value{color:var(--green)}
30
+ .card.fail .value{color:var(--red)}
31
+ .card.skip .value{color:var(--yellow)}
32
+ .card.rate .value{color:var(--accent)}
33
+ .badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:.75rem;font-weight:600}
34
+ .badge-pass{background:#22c55e22;color:var(--green)}
35
+ .badge-fail{background:#ef444422;color:var(--red)}
36
+ .badge-skip{background:#eab30822;color:var(--yellow)}
37
+ .bar{height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:1rem 0}
38
+ .bar-fill{height:100%;border-radius:4px;transition:width .5s}
39
+ .bar-green{background:var(--green)}
40
+ .bar-red{background:var(--red)}
41
+ table{width:100%;border-collapse:collapse;margin:1rem 0}
42
+ th{text-align:left;padding:.75rem 1rem;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}
43
+ td{padding:.75rem 1rem;border-bottom:1px solid var(--border)}
44
+ tr:hover td{background:var(--card-hover)}
45
+ .error-box{background:#1a0000;border:1px solid #ef444444;border-radius:8px;padding:1rem;margin:.5rem 0;font-family:var(--mono);font-size:.8rem;color:#fca5a5;white-space:pre-wrap;overflow-x:auto;max-height:200px;overflow-y:auto}
46
+ .filters{display:flex;gap:.5rem;margin:1rem 0}
47
+ .filter-btn{background:var(--card);border:1px solid var(--border);color:var(--text);padding:.4rem 1rem;border-radius:8px;cursor:pointer;font-size:.85rem;transition:all .2s}
48
+ .filter-btn:hover,.filter-btn.active{background:var(--accent);border-color:var(--accent);color:#fff}
49
+ .layer-section{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;margin:1rem 0}
50
+ .collapsible{cursor:pointer;user-select:none}
51
+ .collapsible::after{content:' \\25BC';font-size:.7rem;color:var(--text-dim)}
52
+ .hidden{display:none}
53
+ footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--text-dim);font-size:.8rem;text-align:center}
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="container">
58
+ <h1>QABot Test Report</h1>
59
+ <div class="subtitle">${esc(meta.projectName || "")} &bull; Feature: ${esc(meta.feature || "all")} &bull; Env: ${esc(meta.environment || "local")} &bull; ${esc(meta.timestamp?.slice(0, 19) || "")}</div>
60
+
61
+ <div class="cards">
62
+ <div class="card"><div class="label">Total Tests</div><div class="value">${summary.totalTests || 0}</div></div>
63
+ <div class="card pass"><div class="label">Passed</div><div class="value">${summary.totalPassed || 0}</div></div>
64
+ <div class="card fail"><div class="label">Failed</div><div class="value">${summary.totalFailed || 0}</div></div>
65
+ <div class="card skip"><div class="label">Skipped</div><div class="value">${summary.totalSkipped || 0}</div></div>
66
+ <div class="card rate"><div class="label">Pass Rate</div><div class="value">${summary.overallPassRate || 0}%</div></div>
67
+ <div class="card"><div class="label">Duration</div><div class="value">${formatMs(meta.duration || summary.totalDuration || 0)}</div></div>
68
+ </div>
69
+
70
+ <div class="bar"><div class="bar-fill ${(summary.overallPassRate || 0) >= 80 ? "bar-green" : "bar-red"}" style="width:${summary.overallPassRate || 0}%"></div></div>
71
+
72
+ ${Object.entries(summary.byLayer || {})
73
+ .map(
74
+ ([layer, s]) => `
75
+ <h2>${layer.charAt(0).toUpperCase() + layer.slice(1)} Tests</h2>
76
+ <div class="layer-section">
77
+ <div>${s.passed} passed, ${s.failed} failed, ${s.skipped} skipped of ${s.total}</div>
78
+ </div>
79
+ `,
80
+ )
81
+ .join("")}
82
+
83
+ <h2>All Test Results</h2>
84
+ <div class="filters">
85
+ <button class="filter-btn active" onclick="filterTests('all')">All (${allTests.length})</button>
86
+ <button class="filter-btn" onclick="filterTests('passed')">Passed (${passedTests.length})</button>
87
+ <button class="filter-btn" onclick="filterTests('failed')">Failed (${failedTests.length})</button>
88
+ <button class="filter-btn" onclick="filterTests('skipped')">Skipped (${skippedTests.length})</button>
89
+ </div>
90
+
91
+ <table>
92
+ <thead><tr><th>Status</th><th>Test Name</th><th>Suite</th><th>Duration</th></tr></thead>
93
+ <tbody>
94
+ ${allTests
95
+ .map(
96
+ (t) => `<tr class="test-row" data-status="${t.status}">
97
+ <td><span class="badge badge-${t.status}">${t.status}</span></td>
98
+ <td>${esc(t.name)}${t.error ? `<div class="error-box">${esc(t.error.message || "")}</div>` : ""}</td>
99
+ <td style="color:var(--text-dim);font-size:.85rem">${esc(t.suite || t.file || "")}</td>
100
+ <td style="color:var(--text-dim)">${formatMs(t.duration || 0)}</td>
101
+ </tr>`,
102
+ )
103
+ .join("\n")}
104
+ </tbody>
105
+ </table>
106
+
107
+ ${
108
+ failedTests.length > 0
109
+ ? `
110
+ <h2>Failed Test Details</h2>
111
+ ${failedTests
112
+ .map(
113
+ (t) => `
114
+ <div class="layer-section">
115
+ <strong style="color:var(--red)">${esc(t.name)}</strong>
116
+ <div style="color:var(--text-dim);font-size:.85rem;margin:.5rem 0">${esc(t.file || "")} &bull; ${esc(t.suite || "")}</div>
117
+ ${t.error ? `<div class="error-box">${esc(t.error.message || "")}\n${esc(t.error.stack || "")}</div>` : ""}
118
+ </div>
119
+ `,
120
+ )
121
+ .join("")}
122
+ `
123
+ : ""
124
+ }
125
+
126
+ <footer>Generated by QABot v0.1.0 &bull; ${new Date().toISOString()}</footer>
127
+ </div>
128
+
129
+ <script>
130
+ function filterTests(status){
131
+ document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
132
+ event.target.classList.add('active');
133
+ document.querySelectorAll('.test-row').forEach(r=>{
134
+ r.style.display=status==='all'||r.dataset.status===status?'':'none';
135
+ });
136
+ }
137
+ </script>
138
+ </body>
139
+ </html>`;
140
+ }
141
+ }
142
+
143
+ function esc(str) {
144
+ return String(str || "")
145
+ .replace(/&/g, "&amp;")
146
+ .replace(/</g, "&lt;")
147
+ .replace(/>/g, "&gt;")
148
+ .replace(/"/g, "&quot;");
149
+ }