Files
bun.sh/test/cli/inspect/junit-reporter.ts
2025-04-29 18:34:26 -07:00

361 lines
9.6 KiB
TypeScript

// This is a test app for:
// - TestReporter.enable
// - TestReporter.found
// - TestReporter.start
// - TestReporter.end
// - Console.messageAdded
// - LifecycleReporter.enable
// - LifecycleReporter.error
const debug = false;
import { listen, type Socket } from "bun";
import { SocketFramer } from "./socket-framer.ts";
import type { JSC } from "../../../packages/bun-inspector-protocol/src/protocol/jsc";
interface Message {
id?: number;
method?: string;
params?: any;
result?: any;
}
export class InspectorSession {
protected messageCallbacks: Map<number, (result: any) => void>;
protected eventListeners: Map<string, ((params: any) => void)[]>;
nextId: number;
framer?: SocketFramer;
socket?: Socket<{ onData: (socket: Socket<any>, data: Buffer) => void }>;
constructor() {
this.messageCallbacks = new Map();
this.eventListeners = new Map();
this.nextId = 1;
}
onMessage(data: string) {
if (debug) console.log(data);
const message: Message = JSON.parse(data);
// console.log({ message });
if (message.id && this.messageCallbacks.has(message.id)) {
const callback = this.messageCallbacks.get(message.id)!;
callback(message.result);
this.messageCallbacks.delete(message.id);
} else if (message.method && this.eventListeners.has(message.method)) {
if (debug) console.log("event", message.method, message.params);
const listeners = this.eventListeners.get(message.method)!;
for (const listener of listeners) {
listener(message.params);
}
}
}
send(method: string, params: any = {}) {
if (!this.framer) throw new Error("Socket not connected");
const id = this.nextId++;
const message = { id, method, params };
this.framer.send(this.socket as any, JSON.stringify(message));
}
addEventListener(method: string, callback: (params: any) => void) {
if (!this.eventListeners.has(method)) {
this.eventListeners.set(method, []);
}
this.eventListeners.get(method)!.push(callback);
}
}
interface JUnitTestCase {
name: string;
classname: string;
time: number;
failure?: {
message: string;
type: string;
content: string;
};
systemOut?: string;
systemErr?: string;
}
interface JUnitTestSuite {
name: string;
tests: number;
failures: number;
errors: number;
skipped: number;
time: number;
timestamp: string;
testCases: JUnitTestCase[];
}
interface TestInfo {
id: number;
name: string;
file: string;
startTime?: number;
stdout: string[];
stderr: string[];
}
export class JUnitReporter {
private session: InspectorSession;
testSuites: Map<string, JUnitTestSuite>;
private tests: Map<number, TestInfo>;
private currentTest: TestInfo | null = null;
constructor(session: InspectorSession) {
this.session = session;
this.testSuites = new Map();
this.tests = new Map();
this.enableDomains();
this.setupEventListeners();
}
protected async enableDomains() {
this.session.send("Inspector.enable");
this.session.send("TestReporter.enable");
this.session.send("LifecycleReporter.enable");
this.session.send("Console.enable");
this.session.send("Runtime.enable");
}
protected setupEventListeners() {
this.session.addEventListener("TestReporter.found", this.handleTestFound.bind(this));
this.session.addEventListener("TestReporter.start", this.handleTestStart.bind(this));
this.session.addEventListener("TestReporter.end", this.handleTestEnd.bind(this));
this.session.addEventListener("Console.messageAdded", this.handleConsoleMessage.bind(this));
this.session.addEventListener("LifecycleReporter.error", this.handleException.bind(this));
}
private getOrCreateTestSuite(file: string): JUnitTestSuite {
if (!this.testSuites.has(file)) {
this.testSuites.set(file, {
name: file,
tests: 0,
failures: 0,
errors: 0,
skipped: 0,
time: 0,
timestamp: new Date().toISOString(),
testCases: [],
});
}
return this.testSuites.get(file)!;
}
private handleTestFound(params: JSC.TestReporter.FoundEvent) {
const file = params.url || "unknown";
const suite = this.getOrCreateTestSuite(file);
suite.tests++;
const test: TestInfo = {
id: params.id,
name: params.name || `Test ${params.id}`,
file,
stdout: [],
stderr: [],
};
this.tests.set(params.id, test);
}
private handleTestStart(params: JSC.TestReporter.StartEvent) {
const test = this.tests.get(params.id);
if (test) {
test.startTime = Date.now();
this.currentTest = test;
}
}
private handleTestEnd(params: JSC.TestReporter.EndEvent) {
const test = this.tests.get(params.id);
if (!test || !test.startTime) return;
const suite = this.getOrCreateTestSuite(test.file);
const testCase: JUnitTestCase = {
name: test.name,
classname: test.file,
time: (Date.now() - test.startTime) / 1000,
};
if (test.stdout.length > 0) {
testCase.systemOut = test.stdout.join("\n");
}
if (params.status === "fail") {
suite.failures++;
testCase.failure = {
message: "Test failed",
type: "AssertionError",
content: test.stderr.join("\n") || "No error details available",
};
test.stderr = [];
} else if (params.status === "skip" || params.status === "todo") {
suite.skipped++;
}
if (test.stderr.length > 0) {
testCase.systemErr = test.stderr.join("\n");
}
suite.testCases.push(testCase);
this.currentTest = null;
}
private handleConsoleMessage(params: any) {
if (!this.currentTest) return;
const message = params.message;
const text = message.text || "";
if (message.level === "error" || message.level === "warning") {
this.currentTest.stderr.push(text);
} else {
this.currentTest.stdout.push(text);
}
}
private handleException(params: JSC.LifecycleReporter.ErrorEvent) {
if (!this.currentTest) return;
const error = params;
let stackTrace = "";
for (let i = 0; i < error.urls.length; i++) {
let url = error.urls[i];
let line = Number(error.lineColumns[i * 2]);
let column = Number(error.lineColumns[i * 2 + 1]);
if (column > 0 && line > 0) {
stackTrace += ` at ${url}:${line}:${column}\n`;
} else if (line > 0) {
stackTrace += ` at ${url}:${line}\n`;
} else {
stackTrace += ` at ${url}\n`;
}
}
this.currentTest.stderr.push(`${error.name || "Error"}: ${error.message || "Unknown error"}`, "");
if (stackTrace) {
this.currentTest.stderr.push(stackTrace);
this.currentTest.stderr.push("");
}
}
generateReport(): string {
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += "<testsuites>\n";
for (const suite of this.testSuites.values()) {
xml += ` <testsuite name="${escapeXml(suite.name)}" `;
xml += `tests="${suite.tests}" `;
xml += `failures="${suite.failures}" `;
xml += `errors="${suite.errors}" `;
xml += `skipped="${suite.skipped}" `;
xml += `timestamp="${suite.timestamp}">\n`;
for (const testCase of suite.testCases) {
xml += ` <testcase classname="${escapeXml(testCase.classname)}" `;
xml += `name="${escapeXml(testCase.name)}" `;
if (testCase.failure) {
xml += ` <failure message="${escapeXml(testCase.failure.message)}" `;
xml += `type="${escapeXml(testCase.failure.type)}">\n`;
xml += ` ${escapeXml(testCase.failure.content)}\n`;
xml += " </failure>\n";
}
if (testCase.systemOut) {
xml += ` <system-out>${escapeXml(testCase.systemOut)}</system-out>\n`;
}
if (testCase.systemErr) {
xml += ` <system-err>${escapeXml(testCase.systemErr)}</system-err>\n`;
}
xml += " </testcase>\n";
}
xml += " </testsuite>\n";
}
xml += "</testsuites>";
return xml;
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export async function connect(
address: string,
onClose?: () => void,
): Promise<Socket<{ onData: (socket: Socket<any>, data: Buffer) => void }>> {
const { promise, resolve } = Promise.withResolvers<Socket<{ onData: (socket: Socket<any>, data: Buffer) => void }>>();
var listener = listen<{ onData: (socket: Socket<any>, data: Buffer) => void }>({
unix: address.slice("unix://".length),
socket: {
open: socket => {
listener.stop();
socket.ref();
resolve(socket);
},
data(socket, data: Buffer) {
socket.data?.onData(socket, data);
},
error(socket, error) {
console.error(error);
},
close(socket) {
if (onClose) {
onClose();
}
},
},
});
return await promise;
}
if (import.meta.main) {
// Main execution
const address = process.argv[2];
if (!address) {
throw new Error("Please provide the inspector address as an argument");
}
let reporter: JUnitReporter;
let session: InspectorSession;
const socket = await connect(address);
const framer = new SocketFramer((message: string) => {
session.onMessage(message);
});
session = new InspectorSession();
session.socket = socket;
session.framer = framer;
socket.data = {
onData: framer.onData.bind(framer),
};
reporter = new JUnitReporter(session);
// Handle process exit
process.on("exit", () => {
if (reporter) {
const report = reporter.generateReport();
console.log(report);
}
});
}