@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,84 @@
1
+ import path from "node:path";
2
+ import { writeFile, readdir, readFile } from "node:fs/promises";
3
+ import { ensureDir, writeJSON } from "../utils/file-utils.js";
4
+ import { HtmlBuilder } from "./html-builder.js";
5
+ import { openInBrowser } from "./report-server.js";
6
+
7
+ export class ReportGenerator {
8
+ constructor(config) {
9
+ this.config = config;
10
+ this.outputDir = config.reporting?.outputDir || "./qabot-reports";
11
+ }
12
+
13
+ async generate(results, meta) {
14
+ const dateStr = new Date().toISOString().slice(0, 10);
15
+ const reportDir = path.join(
16
+ this.outputDir,
17
+ `${dateStr}_${meta.feature || "all"}`,
18
+ );
19
+ await ensureDir(reportDir);
20
+
21
+ const jsonPath = path.join(reportDir, "results.json");
22
+ await writeJSON(jsonPath, results);
23
+
24
+ const htmlBuilder = new HtmlBuilder();
25
+ const html = htmlBuilder.render(results, meta);
26
+ const htmlPath = path.join(reportDir, "index.html");
27
+ await writeFile(htmlPath, html, "utf-8");
28
+
29
+ if (this.config.reporting?.openAfterRun) {
30
+ try {
31
+ await openInBrowser(path.resolve(htmlPath));
32
+ } catch {
33
+ /* noop */
34
+ }
35
+ }
36
+
37
+ return {
38
+ reportDir,
39
+ htmlPath: path.resolve(htmlPath),
40
+ jsonPath: path.resolve(jsonPath),
41
+ };
42
+ }
43
+
44
+ async openLatest() {
45
+ const reports = await this.listReports();
46
+ if (reports.length === 0) throw new Error("No reports found");
47
+ await openInBrowser(reports[0].htmlPath);
48
+ }
49
+
50
+ async listReports() {
51
+ try {
52
+ const entries = await readdir(this.outputDir, { withFileTypes: true });
53
+ const dirs = entries
54
+ .filter((e) => e.isDirectory())
55
+ .map((e) => e.name)
56
+ .sort()
57
+ .reverse();
58
+
59
+ const reports = [];
60
+ for (const dir of dirs) {
61
+ const jsonPath = path.join(this.outputDir, dir, "results.json");
62
+ const htmlPath = path.resolve(
63
+ path.join(this.outputDir, dir, "index.html"),
64
+ );
65
+ try {
66
+ const data = JSON.parse(await readFile(jsonPath, "utf-8"));
67
+ const parts = dir.split("_");
68
+ reports.push({
69
+ date: parts[0] || dir,
70
+ feature: parts.slice(1).join("_") || "all",
71
+ passRate: data.summary?.overallPassRate || 0,
72
+ path: htmlPath,
73
+ htmlPath,
74
+ });
75
+ } catch {
76
+ /* skip broken reports */
77
+ }
78
+ }
79
+ return reports;
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,18 @@
1
+ import { exec } from "node:child_process";
2
+ import { platform } from "node:os";
3
+
4
+ export async function openInBrowser(filePath) {
5
+ const commands = {
6
+ darwin: `open "${filePath}"`,
7
+ win32: `start "" "${filePath}"`,
8
+ linux: `xdg-open "${filePath}"`,
9
+ };
10
+ const cmd = commands[platform()] || commands.linux;
11
+
12
+ return new Promise((resolve, reject) => {
13
+ exec(cmd, (err) => {
14
+ if (err) reject(err);
15
+ else resolve();
16
+ });
17
+ });
18
+ }
@@ -0,0 +1,19 @@
1
+ export class BaseRunner {
2
+ constructor(name, config = {}) {
3
+ this.name = name;
4
+ this.config = config;
5
+ }
6
+
7
+ async isAvailable(projectDir) {
8
+ throw new Error(`${this.name}: isAvailable not implemented`);
9
+ }
10
+ buildCommand(options) {
11
+ throw new Error(`${this.name}: buildCommand not implemented`);
12
+ }
13
+ parseOutput(stdout, stderr, exitCode) {
14
+ throw new Error(`${this.name}: parseOutput not implemented`);
15
+ }
16
+ getDisplayName() {
17
+ return this.name.charAt(0).toUpperCase() + this.name.slice(1);
18
+ }
19
+ }
@@ -0,0 +1,54 @@
1
+ import { BaseRunner } from "./base-runner.js";
2
+ import { fileExists } from "../utils/file-utils.js";
3
+
4
+ export class DotnetRunner extends BaseRunner {
5
+ constructor(config) {
6
+ super("dotnet-test", config);
7
+ }
8
+ getDisplayName() {
9
+ return ".NET Test";
10
+ }
11
+
12
+ async isAvailable() {
13
+ try {
14
+ const { execSync } = await import("node:child_process");
15
+ execSync("dotnet --version", { stdio: "pipe" });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ buildCommand(options = {}) {
23
+ if (this.config.command && options.pattern) {
24
+ return this.config.command.replace("{pattern}", options.pattern);
25
+ }
26
+ const parts = ["dotnet", "test"];
27
+ if (options.pattern) parts.push(`--filter "${options.pattern}"`);
28
+ parts.push('--logger "trx"', "--results-directory ./qabot-test-results");
29
+ if (options.verbose) parts.push("--verbosity normal");
30
+ return parts.join(" ");
31
+ }
32
+
33
+ parseOutput(stdout, stderr, exitCode) {
34
+ const tests = [];
35
+ const passMatch = stdout.match(/Passed:\s+(\d+)/);
36
+ const failMatch = stdout.match(/Failed:\s+(\d+)/);
37
+ const skipMatch = stdout.match(/Skipped:\s+(\d+)/);
38
+ const totalMatch = stdout.match(/Total:\s+(\d+)/);
39
+
40
+ const summary = {
41
+ total: parseInt(totalMatch?.[1] || "0"),
42
+ passed: parseInt(passMatch?.[1] || "0"),
43
+ failed: parseInt(failMatch?.[1] || "0"),
44
+ skipped: parseInt(skipMatch?.[1] || "0"),
45
+ passRate: 0,
46
+ };
47
+ summary.passRate =
48
+ summary.total > 0
49
+ ? Math.round((summary.passed / summary.total) * 100)
50
+ : 0;
51
+
52
+ return { tests, summary, coverage: null };
53
+ }
54
+ }
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+ import { tmpdir } from "node:os";
3
+ import { nanoid } from "nanoid";
4
+ import { BaseRunner } from "./base-runner.js";
5
+ import { fileExists, readJSON } from "../utils/file-utils.js";
6
+
7
+ export class JestRunner extends BaseRunner {
8
+ constructor(config) {
9
+ super("jest", config);
10
+ }
11
+ getDisplayName() {
12
+ return "Jest";
13
+ }
14
+
15
+ async isAvailable(projectDir) {
16
+ return fileExists(path.join(projectDir, "node_modules", ".bin", "jest"));
17
+ }
18
+
19
+ buildCommand(options = {}) {
20
+ if (this.config.command && options.pattern) {
21
+ return this.config.command.replace("{pattern}", options.pattern);
22
+ }
23
+
24
+ const parts = ["npx", "jest"];
25
+ if (options.pattern) parts.push(`--testPathPattern="${options.pattern}"`);
26
+ if (options.coverage) parts.push("--coverage");
27
+
28
+ const jsonFile = path.join(tmpdir(), `qabot-jest-${nanoid(8)}.json`);
29
+ parts.push("--json", `--outputFile="${jsonFile}"`);
30
+ parts.push("--forceExit", "--detectOpenHandles");
31
+ this._jsonFile = jsonFile;
32
+ return parts.join(" ");
33
+ }
34
+
35
+ parseOutput(stdout, stderr, exitCode) {
36
+ const tests = [];
37
+ let jsonData = null;
38
+
39
+ try {
40
+ if (this._jsonFile) {
41
+ jsonData = JSON.parse(
42
+ require("node:fs").readFileSync(this._jsonFile, "utf-8"),
43
+ );
44
+ }
45
+ } catch {
46
+ /* fallback to stdout parsing */
47
+ }
48
+
49
+ if (!jsonData) {
50
+ try {
51
+ const jsonStart = stdout.indexOf("{");
52
+ if (jsonStart !== -1) jsonData = JSON.parse(stdout.slice(jsonStart));
53
+ } catch {
54
+ /* parse manually */
55
+ }
56
+ }
57
+
58
+ if (jsonData && jsonData.testResults) {
59
+ for (const suite of jsonData.testResults) {
60
+ for (const test of suite.assertionResults || []) {
61
+ tests.push({
62
+ name: test.fullName || test.title,
63
+ suite: test.ancestorTitles?.join(" > ") || "",
64
+ file: path.relative(process.cwd(), suite.testFilePath || ""),
65
+ status: mapJestStatus(test.status),
66
+ duration: test.duration || 0,
67
+ error: test.failureMessages?.length
68
+ ? { message: test.failureMessages.join("\n"), stack: "" }
69
+ : null,
70
+ screenshots: [],
71
+ retries: 0,
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ const summary = jsonData
78
+ ? {
79
+ total: jsonData.numTotalTests || 0,
80
+ passed: jsonData.numPassedTests || 0,
81
+ failed: jsonData.numFailedTests || 0,
82
+ skipped:
83
+ (jsonData.numPendingTests || 0) + (jsonData.numTodoTests || 0),
84
+ }
85
+ : parseSummaryFromStdout(stdout);
86
+
87
+ summary.passRate =
88
+ summary.total > 0
89
+ ? Math.round((summary.passed / summary.total) * 100)
90
+ : 0;
91
+
92
+ return { tests, summary, coverage: jsonData?.coverageMap || null };
93
+ }
94
+ }
95
+
96
+ function mapJestStatus(status) {
97
+ const map = {
98
+ passed: "passed",
99
+ failed: "failed",
100
+ pending: "skipped",
101
+ todo: "skipped",
102
+ };
103
+ return map[status] || "skipped";
104
+ }
105
+
106
+ function parseSummaryFromStdout(stdout) {
107
+ const summary = { total: 0, passed: 0, failed: 0, skipped: 0, passRate: 0 };
108
+ const testMatch = stdout.match(
109
+ /Tests:\s+(?:(\d+) failed,\s+)?(?:(\d+) skipped,\s+)?(?:(\d+) passed,\s+)?(\d+) total/,
110
+ );
111
+ if (testMatch) {
112
+ summary.failed = parseInt(testMatch[1] || "0");
113
+ summary.skipped = parseInt(testMatch[2] || "0");
114
+ summary.passed = parseInt(testMatch[3] || "0");
115
+ summary.total = parseInt(testMatch[4] || "0");
116
+ }
117
+ return summary;
118
+ }
@@ -0,0 +1,106 @@
1
+ import path from "node:path";
2
+ import { BaseRunner } from "./base-runner.js";
3
+ import { fileExists } from "../utils/file-utils.js";
4
+
5
+ export class PlaywrightRunner extends BaseRunner {
6
+ constructor(config) {
7
+ super("playwright", config);
8
+ }
9
+ getDisplayName() {
10
+ return "Playwright";
11
+ }
12
+
13
+ async isAvailable(projectDir) {
14
+ return fileExists(
15
+ path.join(projectDir, "node_modules", ".bin", "playwright"),
16
+ );
17
+ }
18
+
19
+ buildCommand(options = {}) {
20
+ if (this.config.command && options.pattern) {
21
+ return this.config.command.replace("{pattern}", options.pattern);
22
+ }
23
+
24
+ const parts = ["npx", "playwright", "test"];
25
+ if (options.pattern) parts.push(options.pattern);
26
+ if (options.project) parts.push(`--project=${options.project}`);
27
+ if (options.headed) parts.push("--headed");
28
+ if (options.grep) parts.push(`--grep="${options.grep}"`);
29
+ parts.push("--reporter=json");
30
+ return parts.join(" ");
31
+ }
32
+
33
+ parseOutput(stdout, stderr, exitCode) {
34
+ const tests = [];
35
+ let jsonData = null;
36
+
37
+ try {
38
+ const jsonStart = stdout.indexOf("{");
39
+ if (jsonStart !== -1) jsonData = JSON.parse(stdout.slice(jsonStart));
40
+ } catch {
41
+ /* fall through to manual parse */
42
+ }
43
+
44
+ if (jsonData?.suites) {
45
+ flattenPlaywrightSuites(jsonData.suites, tests);
46
+ }
47
+
48
+ const summary = {
49
+ total: tests.length,
50
+ passed: tests.filter((t) => t.status === "passed").length,
51
+ failed: tests.filter((t) => t.status === "failed").length,
52
+ skipped: tests.filter((t) => t.status === "skipped").length,
53
+ passRate: 0,
54
+ };
55
+ summary.passRate =
56
+ summary.total > 0
57
+ ? Math.round((summary.passed / summary.total) * 100)
58
+ : 0;
59
+
60
+ return { tests, summary, coverage: null };
61
+ }
62
+ }
63
+
64
+ function flattenPlaywrightSuites(suites, tests, parentTitle = "") {
65
+ for (const suite of suites) {
66
+ const suiteName = parentTitle
67
+ ? `${parentTitle} > ${suite.title}`
68
+ : suite.title;
69
+
70
+ for (const spec of suite.specs || []) {
71
+ for (const test of spec.tests || []) {
72
+ const lastResult = test.results?.[test.results.length - 1];
73
+ tests.push({
74
+ name: spec.title,
75
+ suite: suiteName,
76
+ file: suite.file || "",
77
+ status: mapPlaywrightStatus(test.status || lastResult?.status),
78
+ duration: lastResult?.duration || 0,
79
+ error: lastResult?.error
80
+ ? {
81
+ message: lastResult.error.message || "",
82
+ stack: lastResult.error.stack || "",
83
+ }
84
+ : null,
85
+ screenshots: (lastResult?.attachments || [])
86
+ .filter((a) => a.contentType?.startsWith("image/"))
87
+ .map((a) => a.path),
88
+ retries: (test.results?.length || 1) - 1,
89
+ });
90
+ }
91
+ }
92
+
93
+ if (suite.suites) flattenPlaywrightSuites(suite.suites, tests, suiteName);
94
+ }
95
+ }
96
+
97
+ function mapPlaywrightStatus(status) {
98
+ const map = {
99
+ expected: "passed",
100
+ unexpected: "failed",
101
+ flaky: "passed",
102
+ skipped: "skipped",
103
+ timedOut: "failed",
104
+ };
105
+ return map[status] || status || "skipped";
106
+ }
@@ -0,0 +1,84 @@
1
+ import { BaseRunner } from "./base-runner.js";
2
+
3
+ export class PytestRunner extends BaseRunner {
4
+ constructor(config) {
5
+ super("pytest", config);
6
+ }
7
+ getDisplayName() {
8
+ return "pytest";
9
+ }
10
+
11
+ async isAvailable() {
12
+ try {
13
+ const { execSync } = await import("node:child_process");
14
+ execSync("python3 -m pytest --version", { stdio: "pipe" });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ buildCommand(options = {}) {
22
+ if (this.config.command && options.pattern) {
23
+ return this.config.command.replace("{pattern}", options.pattern);
24
+ }
25
+ const parts = ["python3", "-m", "pytest"];
26
+ if (options.pattern) parts.push(options.pattern);
27
+ parts.push("--tb=short", "-q");
28
+ if (options.verbose) parts.push("-v");
29
+ return parts.join(" ");
30
+ }
31
+
32
+ parseOutput(stdout, stderr, exitCode) {
33
+ const tests = [];
34
+ const lines = stdout.split("\n");
35
+
36
+ for (const line of lines) {
37
+ const passedMatch = line.match(/^(.+?)\s+PASSED/);
38
+ const failedMatch = line.match(/^(.+?)\s+FAILED/);
39
+ if (passedMatch)
40
+ tests.push({
41
+ name: passedMatch[1].trim(),
42
+ suite: "",
43
+ file: "",
44
+ status: "passed",
45
+ duration: 0,
46
+ error: null,
47
+ screenshots: [],
48
+ retries: 0,
49
+ });
50
+ if (failedMatch)
51
+ tests.push({
52
+ name: failedMatch[1].trim(),
53
+ suite: "",
54
+ file: "",
55
+ status: "failed",
56
+ duration: 0,
57
+ error: { message: "Test failed", stack: "" },
58
+ screenshots: [],
59
+ retries: 0,
60
+ });
61
+ }
62
+
63
+ const summaryMatch = stdout.match(
64
+ /(\d+) passed(?:,\s*(\d+) failed)?(?:,\s*(\d+) skipped)?/,
65
+ );
66
+ const summary = {
67
+ total:
68
+ tests.length ||
69
+ parseInt(summaryMatch?.[1] || "0") +
70
+ parseInt(summaryMatch?.[2] || "0") +
71
+ parseInt(summaryMatch?.[3] || "0"),
72
+ passed: parseInt(summaryMatch?.[1] || "0"),
73
+ failed: parseInt(summaryMatch?.[2] || "0"),
74
+ skipped: parseInt(summaryMatch?.[3] || "0"),
75
+ passRate: 0,
76
+ };
77
+ summary.passRate =
78
+ summary.total > 0
79
+ ? Math.round((summary.passed / summary.total) * 100)
80
+ : 0;
81
+
82
+ return { tests, summary, coverage: null };
83
+ }
84
+ }
@@ -0,0 +1,32 @@
1
+ import { JestRunner } from "./jest-runner.js";
2
+ import { VitestRunner } from "./vitest-runner.js";
3
+ import { PlaywrightRunner } from "./playwright-runner.js";
4
+ import { DotnetRunner } from "./dotnet-runner.js";
5
+ import { PytestRunner } from "./pytest-runner.js";
6
+
7
+ const RUNNERS = {
8
+ jest: JestRunner,
9
+ vitest: VitestRunner,
10
+ playwright: PlaywrightRunner,
11
+ cypress: PlaywrightRunner,
12
+ "dotnet-test": DotnetRunner,
13
+ dotnet: DotnetRunner,
14
+ pytest: PytestRunner,
15
+ };
16
+
17
+ export function getRunner(name, config = {}) {
18
+ const RunnerClass = RUNNERS[name];
19
+ if (!RunnerClass)
20
+ throw new Error(
21
+ `Unknown runner: ${name}. Available: ${Object.keys(RUNNERS).join(", ")}`,
22
+ );
23
+ return new RunnerClass(config);
24
+ }
25
+
26
+ export function listRunners() {
27
+ return Object.keys(RUNNERS);
28
+ }
29
+
30
+ export function isKnownRunner(name) {
31
+ return name in RUNNERS;
32
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { BaseRunner } from "./base-runner.js";
3
+ import { fileExists } from "../utils/file-utils.js";
4
+
5
+ export class VitestRunner extends BaseRunner {
6
+ constructor(config) {
7
+ super("vitest", config);
8
+ }
9
+ getDisplayName() {
10
+ return "Vitest";
11
+ }
12
+
13
+ async isAvailable(projectDir) {
14
+ return fileExists(path.join(projectDir, "node_modules", ".bin", "vitest"));
15
+ }
16
+
17
+ buildCommand(options = {}) {
18
+ if (this.config.command && options.pattern) {
19
+ return this.config.command.replace("{pattern}", options.pattern);
20
+ }
21
+ const parts = ["npx", "vitest", "run", "--reporter=json"];
22
+ if (options.pattern) parts.push(options.pattern);
23
+ if (options.coverage) parts.push("--coverage");
24
+ return parts.join(" ");
25
+ }
26
+
27
+ parseOutput(stdout, stderr, exitCode) {
28
+ const tests = [];
29
+ let jsonData = null;
30
+ try {
31
+ const jsonStart = stdout.indexOf("{");
32
+ if (jsonStart !== -1) jsonData = JSON.parse(stdout.slice(jsonStart));
33
+ } catch {
34
+ /* noop */
35
+ }
36
+
37
+ if (jsonData?.testResults) {
38
+ for (const suite of jsonData.testResults) {
39
+ for (const test of suite.assertionResults || []) {
40
+ tests.push({
41
+ name: test.fullName || test.title,
42
+ suite: test.ancestorTitles?.join(" > ") || "",
43
+ file: suite.name || "",
44
+ status:
45
+ test.status === "passed"
46
+ ? "passed"
47
+ : test.status === "failed"
48
+ ? "failed"
49
+ : "skipped",
50
+ duration: test.duration || 0,
51
+ error: test.failureMessages?.length
52
+ ? { message: test.failureMessages.join("\n"), stack: "" }
53
+ : null,
54
+ screenshots: [],
55
+ retries: 0,
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ const summary = {
62
+ total: tests.length,
63
+ passed: tests.filter((t) => t.status === "passed").length,
64
+ failed: tests.filter((t) => t.status === "failed").length,
65
+ skipped: tests.filter((t) => t.status === "skipped").length,
66
+ passRate: 0,
67
+ };
68
+ summary.passRate =
69
+ summary.total > 0
70
+ ? Math.round((summary.passed / summary.total) * 100)
71
+ : 0;
72
+ return { tests, summary, coverage: null };
73
+ }
74
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ readFile,
3
+ writeFile as fsWriteFile,
4
+ mkdir,
5
+ readdir,
6
+ stat,
7
+ access,
8
+ } from "node:fs/promises";
9
+ import { constants } from "node:fs";
10
+ import path from "node:path";
11
+ import { glob } from "glob";
12
+
13
+ export async function fileExists(filePath) {
14
+ try {
15
+ await access(filePath, constants.F_OK);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export async function ensureDir(dirPath) {
23
+ await mkdir(dirPath, { recursive: true });
24
+ }
25
+
26
+ export async function readJSON(filePath) {
27
+ const content = await readFile(filePath, "utf-8");
28
+ return JSON.parse(content);
29
+ }
30
+
31
+ export async function writeJSON(filePath, data) {
32
+ await ensureDir(path.dirname(filePath));
33
+ await fsWriteFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
34
+ }
35
+
36
+ export async function findFiles(dir, pattern) {
37
+ return glob(pattern, { cwd: dir, absolute: true, nodir: true });
38
+ }
39
+
40
+ export async function getProjectRoot(startDir) {
41
+ let current = startDir || process.cwd();
42
+ while (current !== path.dirname(current)) {
43
+ if (
44
+ (await fileExists(path.join(current, "package.json"))) ||
45
+ (await fileExists(path.join(current, ".git")))
46
+ ) {
47
+ return current;
48
+ }
49
+ current = path.dirname(current);
50
+ }
51
+ return startDir || process.cwd();
52
+ }
53
+
54
+ export async function safeReadFile(filePath) {
55
+ try {
56
+ return await readFile(filePath, "utf-8");
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export async function listDirs(dirPath) {
63
+ try {
64
+ const entries = await readdir(dirPath, { withFileTypes: true });
65
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
66
+ } catch {
67
+ return [];
68
+ }
69
+ }