ci: migrate kestra-devtools to npm

This commit is contained in:
Roman Acevedo
2025-09-19 16:41:16 +02:00
parent 0ee753529b
commit 339eb79854
24 changed files with 1 additions and 8303 deletions

View File

@@ -1,32 +0,0 @@
name: kestra-devtools test
on:
pull_request:
branches:
- develop
paths:
- 'dev-tools/kestra-devtools/**'
env:
# to save corepack from itself
COREPACK_INTEGRITY_KEYS: 0
jobs:
test:
name: kestra-devtools tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Npm - install
working-directory: 'dev-tools/kestra-devtools'
run: npm ci
- name: Run tests
working-directory: 'dev-tools/kestra-devtools'
run: npm run test
- name: Npm - Run build
working-directory: 'dev-tools/kestra-devtools'
run: npm run build

View File

@@ -64,9 +64,7 @@ jobs:
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_AUTH_TOKEN }}
run: |
export KESTRA_PWD=$(pwd) && sh -c 'cd dev-tools/kestra-devtools && npm ci && npm run build && node dist/kestra-devtools-cli.cjs generateTestReportSummary --only-errors --ci $KESTRA_PWD' > report.md
cat report.md
run: npx --yes @kestra-io/kestra-devtools generateTestReportSummary --only-errors --ci $(pwd)
# report test
- name: Test - Publish Test Results

View File

@@ -1,4 +0,0 @@
node_modules
dist
coverage
.DS_Store

View File

@@ -1,6 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -1,12 +0,0 @@
// @ts-check
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
{
ignores: ["dist/**", "coverage/**", "node_modules/**"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
{
"name": "kestra-devtools-cli",
"version": "1.0.0",
"description": "a CLI tool to run various dev tasks to build, test, release Kestra",
"bin": {
"my-cli": "dist/kestra-devtools-cli.cjs"
},
"main": "dist/kestra-devtools-cli.cjs",
"files": [
"dist/"
],
"scripts": {
"dev": "vitest --watch",
"build": "vite build && tsc -p tsconfig.types.json",
"test": "npm run lint && vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"format": "prettier --write .",
"prepare": "npm run build",
"start": "node dist/kestra-devtools-cli.cjs",
"link": "npm link",
"unlink": "npm unlink -g my-cli || true"
},
"engines": {
"node": ">=18.0.0"
},
"author": "",
"license": "MIT",
"type": "module",
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/node": "^24.3.1",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.43.0",
"vite": "^7.1.5",
"vitest": "^3.2.4"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"fast-xml-parser": "^5.2.5",
"octokit": "^5.0.3"
}
}

View File

@@ -1,13 +0,0 @@
import { Octokit} from "octokit";
export async function commentPR(githubToken: string, owner: string, repo: string, prNumber: number, content: string){
const octokit = new Octokit({ auth: githubToken });
await octokit.rest.issues.createComment({
owner,
repo,
issue_number:prNumber,
body: content,
});
}

View File

@@ -1,15 +0,0 @@
import core from '@actions/core';
import {context} from '@actions/github';
import {strict as assert} from 'assert';
export function getPRContext():{token: string, owner: string, repo: string, prNumber: number}{
const GITHUB_TOKEN = core.getInput('GITHUB_TOKEN') || process.env.GITHUB_TOKEN;
assert.ok(GITHUB_TOKEN, "GITHUB_TOKEN is mandatory");
assert.ok(context.issue);
assert.ok(context.issue.owner);
assert.ok(context.issue.repo);
assert.ok(context.issue.number);
return {token: GITHUB_TOKEN, owner: context.repo.owner, repo: context.repo.repo, prNumber: context.issue.number }
}

View File

@@ -1,18 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { main } from "./kestra-devtools-cli";
describe("cli tests", () => {
it("prints hello with default", async () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
await main(["node", "cli"]);
expect(spy).toHaveBeenCalledWith("Hello, world!");
spy.mockRestore();
});
it("prints hello with name", async () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
await main(["node", "cli", "Roman"]);
expect(spy).toHaveBeenCalledWith("Hello, Roman!");
spy.mockRestore();
});
});

View File

@@ -1,88 +0,0 @@
// Simple CLI entry point.
// Built to dist/kestra-devtools-cli.cjs with a shebang so it can be executed directly.
import { getWorkingDir } from "./utilities/working-dir";
import {exportTestReportSummary} from "./tests-reporting/export-test-report-summary";
import {getPRContext} from "./github-context";
function parseArgs(argv: string[]) {
// argv[0] = node, argv[1] = script, rest are args
const args = argv.slice(2);
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
flags[k] = v ?? true;
} else if (a.startsWith("-") && a.length > 1) {
const letters = a.slice(1).split("");
letters.forEach((l) => (flags[l] = true));
} else {
positionals.push(a);
}
}
return { flags, positionals };
}
export async function main(argv = process.argv) {
const { flags, positionals } = parseArgs(argv);
if (flags.h || flags.help) {
console.log(`kestra-devtools-cli
Usage:
kestra-devtools-cli [options] [name]
Options:
-h, --help Show help
-v, --version Show version
Examples:
kestra-devtools-cli generateTestReportSummary /Users/roman/Documents/git-repos/kestra --only-errors
`);
return 0;
}
if (positionals[0] === "generateTestReportSummary") {
const dirArg = positionals[1];
if (!dirArg) {
console.error(
"Error: missing working directory argument.\nUsage: kestra-devtools-cli generateTestReportSummary <absolute-path>",
);
return 1;
}
const ci = Boolean(flags["ci"]);
const workingDir = getWorkingDir(dirArg);
const summary = await exportTestReportSummary(workingDir, {
onlyErrors: Boolean(flags["only-errors"]),
githubContext: ci ? getPRContext() : undefined
});
// Print to stdout so it can be piped in CI or viewed in terminal
console.log(summary);
return 0;
}
if (flags.v || flags.version) {
// package.json is not bundled by default; prefer env-injected version if needed.
console.log("kestra-devtools-cli v0.1.0");
return 0;
}
const name = positionals[0] ?? "world";
console.log(`Hello, ${name}!`);
return 0;
}
// If executed directly, run main()
if (import.meta.url === `file://${process.argv[1]}`) {
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -1,20 +0,0 @@
import {commentPR} from "../github-api";
import {WorkingDir} from "../utilities/working-dir";
import {generateTestReportSummary} from "./generate-test-report-summary";
import {strict as assert} from 'assert';
export async function exportTestReportSummary(workingDir: WorkingDir, options?: {
onlyErrors?: boolean,
githubContext?: { token: string, owner: string, repo: string, prNumber: number }
}) {
const report = await generateTestReportSummary(workingDir, {onlyErrors: options?.onlyErrors})
if (options?.githubContext) {
assert.ok(options.githubContext.token, "github token is mandatory");
assert.ok(options.githubContext.owner);
assert.ok(options.githubContext.repo);
assert.ok(options.githubContext.prNumber);
await commentPR(options.githubContext.token, options.githubContext.owner, options.githubContext.repo, options.githubContext.prNumber, report);
}
return report;
}

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { getJavaProjectNameFromBuildAbsolutePath } from "./file-path-utils";
describe("test getJavaProjectNameFromBuildAbsolutePath", () => {
it("should work for Kestra modules paths", async () => {
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/Users/roman/Documents/git-repos/kestra/core/build/test-results/junit/TEST-io.kestra.core.validations.ScheduleValidationTest.xml",
),
).toEqual("core");
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/kestra/runner-memory/build/test-results/junit/open-test-report.xml",
),
).toEqual("runner-memory");
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/kestra-ee/executor/build/test-results/junit/open-test-report.xml",
),
).toEqual("executor");
});
});

View File

@@ -1,10 +0,0 @@
export function getJavaProjectNameFromBuildAbsolutePath(absoluteFilePath: string): string {
const parts = absoluteFilePath.split("/");
const buildIndex = parts.lastIndexOf("build");
if (buildIndex > 0) {
return parts[buildIndex - 1];
}
// return full path if not handled
return absoluteFilePath;
}

View File

@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseJunitModuleReport } from "./parse-junit-module-report";
describe("parse-junit-report test", () => {
it("parse OK for all tests success", async () => {
const junitReport = `
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="io.kestra.core.validations.ScheduleValidationTest" tests="6" skipped="0" failures="0" errors="0" timestamp="2025-09-11T17:32:18.116Z" hostname="Romans-MacBook-Pro.local" time="0.202">
<properties/>
<testcase name="sundayDayOfTheWeekAlias()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="withSecondsValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="lateMaximumDelayValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="intervalValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="nicknameValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.203"/>
<testcase name="cronValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
`;
const res = parseJunitModuleReport(junitReport);
expect(res).toBeDefined();
expect(res.testsuites).toEqual([
{
name: "io.kestra.core.validations.ScheduleValidationTest",
errors: 0,
failures: 0,
skipped: 0,
success: 6,
tests: 6,
status: "success",
time: 0.202,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "withSecondsValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "lateMaximumDelayValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "intervalValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "nicknameValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.203,
status: "success",
},
{
name: "cronValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
],
},
]);
});
it("parse OK for test in error", async () => {
const junitReport = `
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="io.kestra.core.validations.ScheduleValidationTest" tests="1" skipped="0" failures="1" errors="0" timestamp="2025-09-11T17:56:02.292Z" hostname="Romans-MacBook-Pro.local" time="0.265">
<properties/>
<testcase name="intervalValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.043">
<failure message="java.lang.RuntimeException: I failed and this is my log" type="java.lang.RuntimeException">java.lang.RuntimeException: I failed and this is my log
\tat io.kestra.core.validations.ScheduleValidationTest.intervalValidation(ScheduleValidationTest.java:93)
\tat java.base/java.lang.reflect.Method.invoke(Method.java:580)
\tat io.micronaut.test.extensions.junit5.MicronautJunit5Extension$2.proceed(MicronautJunit5Extension.java:142)
\tat io.micronaut.test.extensions.AbstractMicronautExtension.interceptEach(AbstractMicronautExtension.java:162)
\tat io.micronaut.test.extensions.AbstractMicronautExtension.interceptTest(AbstractMicronautExtension.java:119)
\tat io.micronaut.test.extensions.junit5.MicronautJunit5Extension.interceptTestMethod(MicronautJunit5Extension.java:129)
\tat java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
\tat java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
\tat java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
\tat java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
\tat java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
</failure>
</testcase>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
`;
const res = parseJunitModuleReport(junitReport);
expect(res.testsuites).length(1);
expect(res.testsuites[0].testcases).length(1);
expect(res.testsuites[0].testcases[0].status).equal("failed");
expect(res.testsuites[0].testcases[0].message).contain("I failed and this is my log");
expect(res.testsuites[0].testcases[0].details).contain("I failed and this is my log");
expect(res.testsuites[0].testcases[0].details).contain("ForkJoinWorkerThread");
});
});

View File

@@ -1,241 +0,0 @@
import { promises as fs } from "node:fs";
import { XMLParser } from "fast-xml-parser";
export type JUnitModuleReport = {
suites: number;
tests: number;
failures: number;
errors: number;
skipped: number;
success: number;
status: "success" | "failed" | "error" | "skipped";
time: number; // total duration in seconds
testsuites: Array<JunitTestSuite>;
};
export interface JunitTestSuite {
name?: string;
tests: number;
failures: number;
errors: number;
skipped: number;
success: number;
status: "success" | "failed" | "error" | "skipped";
time: number;
testcases: Array<JunitTestCase>;
}
export interface JunitTestCase {
classname?: string;
name: string;
time?: number;
status: "success" | "failed" | "error" | "skipped";
message?: string;
type?: string;
details?: string;
}
// for more info on the Junit test report format = https://github.com/testmoapp/junitxml
export function parseJunitModuleReport(xml: string): JUnitModuleReport {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
allowBooleanAttributes: true,
parseAttributeValue: true,
trimValues: false,
});
const obj = parser.parse(xml);
// JUnit can be either <testsuites> or a single <testsuite>
const rawSuites = obj?.testsuites?.testsuite ?? obj?.testsuite ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const suites = toArray<any>(rawSuites);
const report: JUnitModuleReport = {
suites: suites.length,
tests: 0,
failures: 0,
errors: 0,
skipped: 0,
success: 0,
status: "success",
time: 0,
testsuites: [],
};
for (const s of suites) {
const name: string | undefined = s.name;
// Attributes may exist on the suite OR we may need to infer from testcases
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testcases = toArray<any>(s.testcase ?? []);
const suiteCounts = {
tests: numeric(s.tests, testcases.length),
failures: numeric(s.failures, 0),
errors: numeric(s.errors, 0),
skipped: numeric(s.skipped, 0),
time: numeric(s.time, sum(testcases.map((tc) => numeric(tc.time, 0)))),
};
// If suite attributes missing, infer from testcases
if (
!isFiniteNumber(s.failures) ||
!isFiniteNumber(s.errors) ||
!isFiniteNumber(s.skipped)
) {
let f = 0,
e = 0,
sk = 0;
for (const tc of testcases) {
if (hasKey(tc, "failed")) f += toArray(tc.failed).length;
if (hasKey(tc, "error")) e += toArray(tc.error).length;
if (hasKey(tc, "skipped")) sk += toArray(tc.skipped).length || 1; // some producers put empty <skipped/>
}
if (!isFiniteNumber(suiteCounts.failures)) suiteCounts.failures = f;
if (!isFiniteNumber(suiteCounts.errors)) suiteCounts.errors = e;
if (!isFiniteNumber(suiteCounts.skipped)) suiteCounts.skipped = sk;
}
const successCount =
suiteCounts.tests - suiteCounts.errors - suiteCounts.failures - suiteCounts.skipped;
let suiteStatus: "success" | "failed" | "error" | "skipped" = "success";
if (suiteCounts.skipped === suiteCounts.tests) {
suiteStatus = "skipped";
} else if (suiteCounts.errors > 0) {
suiteStatus = "error";
} else if (suiteCounts.failures > 0) {
suiteStatus = "failed";
}
const suiteDetail: JunitTestSuite = {
name,
tests: suiteCounts.tests,
failures: suiteCounts.failures,
errors: suiteCounts.errors,
skipped: suiteCounts.skipped,
success: successCount,
status: suiteStatus,
time: suiteCounts.time,
testcases: [],
};
// Collect failed tests and build suiteDetail.testcases
for (const tc of testcases) {
const classname: string | undefined = tc.classname;
const nameTc: string = tc.name;
const time: number | undefined = isFiniteNumber(tc.time) ? Number(tc.time) : undefined;
// Determine status
if (tc.failure) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "failed",
message: tc.failure.message,
type: tc.failure.type,
details: textContent(tc.failure),
});
} else if (tc.error) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "error",
message: tc.error.message,
type: tc.error.message.type,
details: textContent(tc.error),
});
} else if (tc.skipped) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "skipped",
message: tc.skipped.message,
details: textContent(tc.skipped),
});
} else {
// success test
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "success",
});
}
}
report.tests += suiteCounts.tests;
report.failures += suiteCounts.failures;
report.errors += suiteCounts.errors;
report.skipped += suiteCounts.skipped;
report.success += suiteDetail.success;
report.time += suiteCounts.time;
report.testsuites.push(suiteDetail);
}
if (report.skipped === report.tests) {
report.status = "skipped";
} else if (report.errors > 0) {
report.status = "error";
} else if (report.failures > 0) {
report.status = "failed";
} else {
report.status = "success";
}
return report;
}
/**
* Convenience: parse a file from disk.
*/
export async function summarizeJunitReportFromFile(filePath: string): Promise<JUnitModuleReport> {
const xml = await fs.readFile(filePath, "utf8");
return parseJunitModuleReport(xml);
}
// -------------------- helpers --------------------
function toArray<T>(v: T | T[] | undefined | null): T[] {
if (v == null) return [];
return Array.isArray(v) ? v : [v];
}
function numeric<T>(value: T, fallback = 0): number {
const n = Number(value as unknown);
return Number.isFinite(n) ? n : fallback;
}
function sum(nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
function isFiniteNumber(v: unknown): v is number {
const n = Number(v);
return Number.isFinite(n);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
// Some producers put the text of <failed> / <error> inside `#text` or as the value itself.
function textContent(node: unknown): string | undefined {
if (node == null) return undefined;
if (typeof node === "string") return node;
if (isRecord(node) && typeof node["#text"] === "string") {
return node["#text"] as string;
}
return undefined;
}
function hasKey<O>(obj: O, key: PropertyKey): key is keyof O {
return obj != null && Object.prototype.hasOwnProperty.call(obj, key);
}

View File

@@ -1,157 +0,0 @@
import { describe, expect, it } from "vitest";
import { summarizeJunitReport, TestReport } from "./summarize-junit-report";
describe("summarize-junit-report test", () => {
const testReportsWithGreenTests: TestReport[] = [
{
projectName: "java-module-1",
projectReport: {
errors: 0,
skipped: 0,
failures: 0,
success: 1,
status: "success",
tests: 1,
time: 3,
suites: 1,
testsuites: [
{
name: "io.kestra.core.some.Test",
errors: 0,
skipped: 0,
failures: 0,
success: 1,
status: "success",
tests: 1,
time: 3,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.some.Test",
time: 3,
status: "success",
},
],
},
],
},
},
];
it("summarizeJunitReport for one green module", async () => {
const res = summarizeJunitReport(testReportsWithGreenTests);
expect(res.hasErrors).equal(false);
expect(res.markdownContent).contains("java-module-1");
expect(res.markdownContent).contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("io.kestra.core.some.Test");
});
it("summarizeJunitReport for one green module should not print tests when onlyErrors:true", async () => {
const res = summarizeJunitReport(testReportsWithGreenTests, { onlyErrors: true });
expect(res.hasErrors).equal(false);
expect(res.markdownContent).contains("java-module-1");
expect(res.markdownContent).not.contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).not.contains(
"io.kestra.core.validations.ScheduleValidationTest",
);
});
const testReportWithFailedTests: TestReport[] = [
{
projectName: "java-module-1",
projectReport: {
errors: 0,
skipped: 0,
failures: 1,
success: 1,
status: "failed",
tests: 2,
time: 3,
suites: 1,
testsuites: [
{
name: "io.kestra.core.someother.Test2",
errors: 0,
skipped: 0,
failures: 1,
success: 1,
status: "failed",
tests: 2,
time: 3,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.someother.Test2",
time: 3,
status: "success",
},
{
name: "failingTest()",
classname: "io.kestra.core.someother.Test2",
time: 3,
status: "failed",
message: "java.lang.RuntimeException: I failed and this is my log",
details: "this is the error logs details",
},
],
},
],
},
},
];
it("summarizeJunitReport for failed tests should summarize all by default without details", async () => {
const res = summarizeJunitReport(testReportWithFailedTests);
expect(res.hasErrors).equal(true);
expect(res.markdownContent).contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("failingTest()");
expect(res.markdownContent).contains(
"java.lang.RuntimeException: I failed and this is my log",
);
expect(res.markdownContent).not.contains("this is the error logs details");
});
it("summarizeJunitReport for failed tests should summarize only errors with details when onlyErrors:true", async () => {
const res = summarizeJunitReport(testReportWithFailedTests, { onlyErrors: true });
expect(res.hasErrors).equal(true);
expect(res.markdownContent).not.contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("failingTest()");
expect(res.markdownContent).contains(
"java.lang.RuntimeException: I failed and this is my log",
);
expect(res.markdownContent).contains("this is the error logs details");
});
it("summarizeJunitReport should merge module reports", async () => {
// given 1 report the module name should appear twice
const res1 = summarizeJunitReport(testReportWithFailedTests, { onlyErrors: true });
expect(res1.hasErrors).equal(true);
expect(res1.markdownContent).contain("java-module-1");
expect((res1.markdownContent.match(/java-module-1/g) || []).length).toBe(2);
// given 2 reports for the same module, but for different tests
const reports = [...testReportsWithGreenTests, ...testReportWithFailedTests]
const res2 = summarizeJunitReport(reports, { onlyErrors: true });
expect(res2.hasErrors).equal(true);
expect(res2.markdownContent).contain("java-module-1");
// it should not be duplicated
expect((res2.markdownContent.match(/java-module-1/g) || []).length).toBe(2);
});
it("summarizeJunitReport should print totals", async () => {
// given 2 reports
const reports = [...testReportsWithGreenTests, ...testReportWithFailedTests]
const res = summarizeJunitReport(reports, { onlyErrors: true });
// it should contains added/merged totals
expect(res.markdownContent).contain("tests: 3");
expect(res.markdownContent).contain("failed: 1");
expect(res.markdownContent).contain("success: 2");
expect(res.markdownContent).contain("skipped: 0");
});
});

View File

@@ -1,196 +0,0 @@
import { JUnitModuleReport } from "./parse-junit-module-report";
export type MarkdownString = string;
export interface TestReport {
projectName: string;
projectReport: JUnitModuleReport;
}
export interface TestReportSummary {
hasErrors: boolean;
markdownContent: MarkdownString;
}
export function summarizeJunitReport(
testReports: TestReport[],
options?: { onlyErrors: boolean },
): TestReportSummary {
const onlyErrors = options?.onlyErrors ?? false;
const testReportQuickSummaryRows: string[] = [];
const testReportDetailsRows: string[] = [];
const testReportErrorLogs: string[] = [];
let hasErrors = false;
const mergedReports = mergeSameProjectReports(testReports);
for (const report of mergedReports) {
const project = report.projectName;
const projectReport: JUnitModuleReport = report.projectReport;
testReportQuickSummaryRows.push(
`| ${escapePipe(report.projectName)} | ${escapePipe(mapStatusToEmoji(projectReport.status))} | ${escapePipe(projectReport.success)} | ${escapePipe(projectReport.skipped)} | ${projectReport.errors + projectReport.failures} |`,
);
for (const testsuite of projectReport.testsuites) {
for (const testcase of testsuite.testcases) {
const name = testcase.name ?? "";
const duration = safeNum(testcase.time);
const failed = testcase.status === "failed" || testcase.status === "error";
if (failed) hasErrors = true;
if (onlyErrors) {
// then only print errors, and details like logs
if (failed) {
const message = testcase.message ?? "";
const details = testcase.details ? "\n\n" + testcase.details : "";
const errorSummary= `${escapePipe(project)} > ${escapePipe(testsuite.name)} > ${escapePipe(name)} ${mapStatusToEmoji(testcase.status)} in ${duration}`;
testReportErrorLogs.push(
`${spoilerBlock(errorSummary, codeBlock(message + details))}\n`,
);
}
} else {
testReportDetailsRows.push(
`| ${escapePipe(project)} | ${escapePipe(testsuite.name)} | ${escapePipe(name)} | ${mapStatusToEmoji(testcase.status)} | ${duration} | ${escapePipe(truncate(testcase.message ?? "", 200))} |`,
);
}
}
}
}
let markdownContent = "## Tests report quick summary:";
const totalTests = testReports.map(r => r.projectReport.tests).reduce((a,b) => a+b);
const totalSuccess = testReports.map(r => r.projectReport.success).reduce((a,b) => a+b);
const totalSkipped = testReports.map(r => r.projectReport.skipped).reduce((a,b) => a+b);
const totalErrors = testReports.map(r => r.projectReport.failures + r.projectReport.errors).reduce((a,b) => a+b);
markdownContent = markdownContent + `\ntotals > tests: ${totalTests}, success: ${totalSuccess}, skipped: ${totalSkipped}, failed: ${totalErrors}\n`;
markdownContent =
markdownContent +
`\n| Project | Status | Success | Skipped | Failed |\n|---|---|---|---|---|`;
markdownContent = markdownContent + "\n" + [...testReportQuickSummaryRows].join("\n");
if (testReportDetailsRows.length > 0) {
markdownContent = markdownContent + "\n\n" + "## Tests report details:";
const header = `| Project | Suite | Test | Status | Duration (s) | Message |\n|---|---|---|---|---:|---|`;
markdownContent = markdownContent + "\n" + [header, ...testReportDetailsRows].join("\n");
}
if (testReportErrorLogs.length > 0) {
markdownContent = markdownContent + "\n## Failed tests:";
markdownContent = markdownContent + "\n" + [...testReportErrorLogs].join("\n");
}
return { hasErrors, markdownContent };
// merge reports that share the same projectName by concatenating testsuites
function mergeSameProjectReports(reports: TestReport[]): TestReport[] {
const byProject = new Map<string, JUnitModuleReport>();
for (const r of reports) {
const key = r.projectName;
const existing = byProject.get(key);
if (!existing) {
// clone a shallow copy so we don't mutate the original
const cloned: JUnitModuleReport = {
...r.projectReport,
testsuites: [...r.projectReport.testsuites],
} as JUnitModuleReport;
computeModuleAggregates(cloned);
byProject.set(key, cloned);
} else {
// concatenate testsuites and recompute aggregates
existing.testsuites = [...existing.testsuites, ...r.projectReport.testsuites];
computeModuleAggregates(existing);
}
}
// rebuild TestReport array
return Array.from(byProject.entries()).map(([projectName, projectReport]) => ({
projectName,
projectReport,
}));
}
// recompute success/skip/error/failure counts and overall status from testcases
function computeModuleAggregates(moduleReport: JUnitModuleReport): void {
let success = 0;
let skipped = 0;
let errors = 0;
let failures = 0;
for (const suite of moduleReport.testsuites) {
for (const tc of suite.testcases) {
switch (tc.status) {
case "success":
success++; break;
case "skipped":
skipped++; break;
case "error":
errors++; break;
case "failed":
failures++; break;
}
}
}
const total = success + skipped + errors + failures;
// update known aggregate fields if present on the type
moduleReport.success = success;
moduleReport.skipped = skipped;
moduleReport.errors = errors;
moduleReport.failures = failures;
if ("tests" in moduleReport) {
moduleReport.tests = total;
}
// status rules: all skipped => skipped; any error => error; any failed => failed; else success
let status: "success" | "failed" | "error" | "skipped";
if (total > 0 && skipped === total) status = "skipped";
else if (errors > 0) status = "error";
else if (failures > 0) status = "failed";
else status = "success";
moduleReport.status = status;
}
// helpers scoped below
function escapePipe(s: string | number | undefined): string {
const str = s == null ? "" : String(s);
// escape pipe and newlines for markdown table cells
return str.replace(/\|/g, "\\|").replace(/\r?\n/g, " ↵ ");
}
function codeBlock(s: string | number | undefined): string {
const str = s == null ? "" : String(s);
return `\`\`\`\n${str}\n\`\`\`\n`;
}
function spoilerBlock(summary: string, content: string): string {
return `<details>
<summary>${summary}</summary>
${content}
</details>`;
}
function truncate(s: string, max: number): string {
return s && s.length > max ? s.slice(0, max - 1) + "…" : s || "";
}
function safeNum(v: number | undefined): string {
if (v === undefined || v === null) return "";
const n = typeof v === "number" ? v : Number(String(v));
if (Number.isFinite(n)) return n.toFixed(3).replace(/\.000$/, "");
return String(v);
}
function mapStatusToEmoji(status: "success" | "failed" | "error" | "skipped"): string {
switch (status) {
case "failed":
return "failed ❌";
case "error":
return "error ❌";
case "skipped":
return "skipped ⏭️";
case "success":
return "success ✅";
default:
throw new Error("Unhandled case");
}
}
}

View File

@@ -1,43 +0,0 @@
import {WorkingDir} from "../utilities/working-dir";
import {MarkdownString, summarizeJunitReport, TestReport,} from "./functions/summarize-junit-report";
import {parseJunitModuleReport} from "./functions/parse-junit-module-report";
import fg from "fast-glob";
import fs from "fs";
import {getJavaProjectNameFromBuildAbsolutePath} from "./functions/file-path-utils";
/**
* parse files located at 'testReportsLocationPattern' and generate a summary in Markdown
* @param workingDir
* @param options
*/
export async function generateTestReportSummary(
workingDir: WorkingDir,
options?: {
onlyErrors?: boolean;
testReportsLocationPattern?: "**/build/test-results/test/*.xml";
},
): Promise<MarkdownString> {
const onlyErrors = options?.onlyErrors ?? false;
const pattern = options?.testReportsLocationPattern ?? "**/build/test-results/test/*.xml";
// Find matching report files under the provided working directory
const junitXmlReportsFilenames = await fg.async(pattern, {
cwd: workingDir,
absolute: true,
onlyFiles: true,
dot: true,
followSymbolicLinks: true,
});
// Parse each JUnit report into a module-level structure
const moduleReports: TestReport[] = junitXmlReportsFilenames.map((file) => {
const content = fs.readFileSync(file, "utf-8");
return {
projectName: getJavaProjectNameFromBuildAbsolutePath(file),
projectReport: parseJunitModuleReport(content),
};
});
// Summarize all parsed reports into a single Markdown string
return summarizeJunitReport(moduleReports, {onlyErrors: onlyErrors}).markdownContent;
}

View File

@@ -1,30 +0,0 @@
import fs from "fs";
import path from "path";
export type WorkingDir = string;
/**
* helper to handle working dir passed in CLI
* @param workingDir by default the repository root
*/
export function getWorkingDir(workingDir?: string): WorkingDir {
if (!workingDir) {
throw new Error(
"an absolute working dir is for required, this can be improved for better DX",
);
}
if (!path.isAbsolute(workingDir)) {
throw new Error(`Working directory must be an absolute path: ${workingDir}`);
}
if (!fs.existsSync(workingDir)) {
throw new Error(`Working directory does not exist: ${workingDir}`);
}
const stat = fs.statSync(workingDir);
if (!stat.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDir}`);
}
return workingDir;
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "vitest"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts", "eslint.config.js"]
}

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types"
},
"include": ["src"]
}

View File

@@ -1,25 +0,0 @@
import { defineConfig } from "vite";
import { builtinModules } from "node:module";
// ensure Node-builtins stay external
const externals = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)];
export default defineConfig({
build: {
target: "node18",
outDir: "dist",
emptyOutDir: true,
lib: {
entry: "src/kestra-devtools-cli.ts",
formats: ["cjs"],
fileName: () => "kestra-devtools-cli.cjs",
},
rollupOptions: {
external: externals,
output: {
// Make the output an executable CLI
banner: "#!/usr/bin/env node",
},
},
},
});

View File

@@ -1,12 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
reporter: ["text", "html"],
reportsDirectory: "coverage",
},
},
});