@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.
- package/bin/qabot.js +24 -0
- package/package.json +56 -0
- package/src/ai/ai-engine.js +114 -0
- package/src/ai/prompt-builder.js +86 -0
- package/src/ai/usecase-parser.js +124 -0
- package/src/analyzers/env-detector.js +110 -0
- package/src/analyzers/feature-detector.js +298 -0
- package/src/analyzers/project-analyzer.js +157 -0
- package/src/analyzers/test-detector.js +158 -0
- package/src/cli/commands/generate.js +125 -0
- package/src/cli/commands/init.js +137 -0
- package/src/cli/commands/list.js +130 -0
- package/src/cli/commands/report.js +46 -0
- package/src/cli/commands/run.js +132 -0
- package/src/core/config.js +71 -0
- package/src/core/constants.js +70 -0
- package/src/core/logger.js +104 -0
- package/src/executor/process-manager.js +82 -0
- package/src/executor/result-collector.js +81 -0
- package/src/executor/test-executor.js +124 -0
- package/src/index.js +12 -0
- package/src/reporter/html-builder.js +149 -0
- package/src/reporter/report-generator.js +84 -0
- package/src/reporter/report-server.js +18 -0
- package/src/runners/base-runner.js +19 -0
- package/src/runners/dotnet-runner.js +54 -0
- package/src/runners/jest-runner.js +118 -0
- package/src/runners/playwright-runner.js +106 -0
- package/src/runners/pytest-runner.js +84 -0
- package/src/runners/runner-registry.js +32 -0
- package/src/runners/vitest-runner.js +74 -0
- package/src/utils/file-utils.js +69 -0
- package/templates/configs/default.config.js +79 -0
|
@@ -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 || "")} • Feature: ${esc(meta.feature || "all")} • Env: ${esc(meta.environment || "local")} • ${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 || "")} • ${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 • ${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, "&")
|
|
146
|
+
.replace(/</g, "<")
|
|
147
|
+
.replace(/>/g, ">")
|
|
148
|
+
.replace(/"/g, """);
|
|
149
|
+
}
|