From f2ec92e1e76cc14f6952cf549107015f8a75fbdd Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 17 Nov 2024 07:00:25 -0800 Subject: [PATCH] okay it works now --- src/bun.js/bindings/BunDebugger.cpp | 14 +- .../bindings/InspectorLifecycleAgent.cpp | 7 +- src/bun.js/bindings/InspectorLifecycleAgent.h | 1 + .../bindings/InspectorTestReporterAgent.cpp | 4 +- .../bindings/InspectorTestReporterAgent.h | 1 + src/cli/test_command.zig | 2 + src/js/internal/debugger.ts | 1 + test/cli/inspect/junit-reporter.ts | 351 ++++++++++++++++++ test/cli/inspect/socket-framer.ts | 79 ++++ 9 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 test/cli/inspect/junit-reporter.ts create mode 100644 test/cli/inspect/socket-framer.ts diff --git a/src/bun.js/bindings/BunDebugger.cpp b/src/bun.js/bindings/BunDebugger.cpp index c8632b81ce..15f27ca9ee 100644 --- a/src/bun.js/bindings/BunDebugger.cpp +++ b/src/bun.js/bindings/BunDebugger.cpp @@ -106,6 +106,15 @@ public: inspector.setInspectable(true); globalObject->inspectorController().connectFrontend(*this, true, false); // waitingForConnection + static bool hasConnected = false; + + if (!hasConnected) { + hasConnected = true; + globalObject->inspectorController().registerAlternateAgent( + WTF::makeUnique(*globalObject)); + globalObject->inspectorController().registerAlternateAgent( + WTF::makeUnique(*globalObject)); + } Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); if (debugger) { @@ -495,11 +504,6 @@ extern "C" void Bun__ensureDebugger(ScriptExecutionContextIdentifier scriptId, b auto& inspector = globalObject->inspectorDebuggable(); inspector.setInspectable(true); - globalObject->inspectorController().registerAlternateAgent( - WTF::makeUnique(*globalObject)); - globalObject->inspectorController().registerAlternateAgent( - WTF::makeUnique(*globalObject)); - Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); if (debugger) { debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void { diff --git a/src/bun.js/bindings/InspectorLifecycleAgent.cpp b/src/bun.js/bindings/InspectorLifecycleAgent.cpp index fafa164506..7d3362918e 100644 --- a/src/bun.js/bindings/InspectorLifecycleAgent.cpp +++ b/src/bun.js/bindings/InspectorLifecycleAgent.cpp @@ -40,6 +40,8 @@ void Bun__LifecycleAgentStopPreventingExit(Inspector::InspectorLifecycleAgent* a InspectorLifecycleAgent::InspectorLifecycleAgent(JSC::JSGlobalObject& globalObject) : InspectorAgentBase("LifecycleReporter"_s) , m_globalObject(globalObject) + , m_backendDispatcher(LifecycleReporterBackendDispatcher::create(m_globalObject.inspectorController().backendDispatcher(), this)) + , m_frontendDispatcher(makeUnique(const_cast(m_globalObject.inspectorController().frontendRouter()))) { } @@ -52,7 +54,6 @@ InspectorLifecycleAgent::~InspectorLifecycleAgent() void InspectorLifecycleAgent::didCreateFrontendAndBackend(FrontendRouter*, BackendDispatcher*) { - this->m_frontendDispatcher = makeUnique(const_cast(m_globalObject.inspectorController().frontendRouter())); } void InspectorLifecycleAgent::willDestroyFrontendAndBackend(DisconnectReason) @@ -82,7 +83,7 @@ Protocol::ErrorStringOr InspectorLifecycleAgent::disable() void InspectorLifecycleAgent::reportReload() { - if (!m_enabled || !m_frontendDispatcher) + if (!m_enabled) return; m_frontendDispatcher->reload(); @@ -90,7 +91,7 @@ void InspectorLifecycleAgent::reportReload() void InspectorLifecycleAgent::reportError(ZigException& exception) { - if (!m_enabled || !m_frontendDispatcher) + if (!m_enabled) return; String message = exception.message.toWTFString(); diff --git a/src/bun.js/bindings/InspectorLifecycleAgent.h b/src/bun.js/bindings/InspectorLifecycleAgent.h index 82613d3562..5990b833f6 100644 --- a/src/bun.js/bindings/InspectorLifecycleAgent.h +++ b/src/bun.js/bindings/InspectorLifecycleAgent.h @@ -40,6 +40,7 @@ public: private: JSC::JSGlobalObject& m_globalObject; std::unique_ptr m_frontendDispatcher; + Ref m_backendDispatcher; bool m_enabled { false }; bool m_preventingExit { false }; }; diff --git a/src/bun.js/bindings/InspectorTestReporterAgent.cpp b/src/bun.js/bindings/InspectorTestReporterAgent.cpp index bf95df11ec..dad53f2b54 100644 --- a/src/bun.js/bindings/InspectorTestReporterAgent.cpp +++ b/src/bun.js/bindings/InspectorTestReporterAgent.cpp @@ -68,6 +68,8 @@ void Bun__TestReporterAgentReportTestEnd(Inspector::InspectorTestReporterAgent* InspectorTestReporterAgent::InspectorTestReporterAgent(JSC::JSGlobalObject& globalObject) : InspectorAgentBase("TestReporter"_s) , m_globalObject(globalObject) + , m_backendDispatcher(TestReporterBackendDispatcher::create(m_globalObject.inspectorController().backendDispatcher(), this)) + , m_frontendDispatcher(makeUnique(const_cast(m_globalObject.inspectorController().frontendRouter()))) { } @@ -111,7 +113,7 @@ Protocol::ErrorStringOr InspectorTestReporterAgent::disable() void InspectorTestReporterAgent::reportTestFound(JSC::CallFrame* callFrame, int testId, const String& name) { - if (!m_enabled || !m_frontendDispatcher) + if (!m_enabled) return; JSC::LineColumn lineColumn; diff --git a/src/bun.js/bindings/InspectorTestReporterAgent.h b/src/bun.js/bindings/InspectorTestReporterAgent.h index 32297bb89b..7c6afcf2e0 100644 --- a/src/bun.js/bindings/InspectorTestReporterAgent.h +++ b/src/bun.js/bindings/InspectorTestReporterAgent.h @@ -39,6 +39,7 @@ public: private: JSC::JSGlobalObject& m_globalObject; std::unique_ptr m_frontendDispatcher; + Ref m_backendDispatcher; bool m_enabled { false }; }; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index ccfaad41c3..24f7d3fd1b 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -848,6 +848,8 @@ pub const TestCommand = struct { var results = try std.ArrayList(PathString).initCapacity(ctx.allocator, ctx.positionals.len); defer results.deinit(); + // Start the debugger before we scan for files + // But, don't block the main thread waiting if they used --inspect-wait. const test_files, const search_count = scan: { if (for (ctx.positionals) |arg| { if (std.fs.path.isAbsolute(arg) or diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 8ca8403f5c..8181d05045 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -273,6 +273,7 @@ class Debugger { framer, backend, }; + socket.ref(); }, data: (socket, bytes) => { if (!socket.data) { diff --git a/test/cli/inspect/junit-reporter.ts b/test/cli/inspect/junit-reporter.ts new file mode 100644 index 0000000000..7185435fee --- /dev/null +++ b/test/cli/inspect/junit-reporter.ts @@ -0,0 +1,351 @@ +// 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; +} + +class InspectorSession { + private messageCallbacks: Map void>; + private eventListeners: Map void)[]>; + private nextId: number; + framer?: SocketFramer; + socket?: Socket<{ onData: (socket: Socket, 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); + + 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[]; +} + +class JUnitReporter { + private session: InspectorSession; + private testSuites: Map; + private tests: Map; + private currentTest: TestInfo | null = null; + + constructor(session: InspectorSession) { + this.session = session; + this.testSuites = new Map(); + this.tests = new Map(); + + this.enableDomains(); + this.setupEventListeners(); + } + + private 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"); + } + + private 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 (test.stderr.length > 0) { + testCase.systemErr = test.stderr.join("\n"); + } + + if (params.status === "fail") { + suite.failures++; + testCase.failure = { + message: "Test failed", + type: "AssertionError", + content: test.stderr.join("\n") || "No error details available", + }; + } else if (params.status === "skip" || params.status === "todo") { + suite.skipped++; + } + + 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 = '\n'; + xml += "\n"; + + for (const suite of this.testSuites.values()) { + const totalTime = suite.testCases.reduce((sum, test) => sum + test.time, 0); + suite.time = totalTime; + + xml += ` \n`; + + for (const testCase of suite.testCases) { + xml += ` \n`; + + if (testCase.failure) { + xml += ` \n`; + xml += ` ${escapeXml(testCase.failure.content)}\n`; + xml += " \n"; + } + + if (testCase.systemOut) { + xml += ` ${escapeXml(testCase.systemOut)}\n`; + } + + if (testCase.systemErr) { + xml += ` ${escapeXml(testCase.systemErr)}\n`; + } + + xml += " \n"; + } + + xml += " \n"; + } + + xml += ""; + return xml; + } +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +async function connect(address: string): Promise, data: Buffer) => void }>> { + const { promise, resolve } = Promise.withResolvers, data: Buffer) => void }>>(); + + var listener = listen<{ onData: (socket: Socket, 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); + }, + }, + }); + + return await promise; +} + +// 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); + } +}); diff --git a/test/cli/inspect/socket-framer.ts b/test/cli/inspect/socket-framer.ts new file mode 100644 index 0000000000..fea0908cc5 --- /dev/null +++ b/test/cli/inspect/socket-framer.ts @@ -0,0 +1,79 @@ +interface Socket { + data: T; + write(data: string | Buffer): void; +} + +const enum FramerState { + WaitingForLength, + WaitingForMessage, +} + +let socketFramerMessageLengthBuffer: Buffer; +export class SocketFramer { + private state: FramerState = FramerState.WaitingForLength; + private pendingLength: number = 0; + private sizeBuffer: Buffer = Buffer.alloc(0); + private sizeBufferIndex: number = 0; + private bufferedData: Buffer = Buffer.alloc(0); + + constructor(private onMessage: (message: string) => void) { + if (!socketFramerMessageLengthBuffer) { + socketFramerMessageLengthBuffer = Buffer.alloc(4); + } + this.reset(); + } + + reset(): void { + this.state = FramerState.WaitingForLength; + this.bufferedData = Buffer.alloc(0); + this.sizeBufferIndex = 0; + this.sizeBuffer = Buffer.alloc(4); + } + + send(socket: Socket, data: string): void { + socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0); + socket.write(socketFramerMessageLengthBuffer); + socket.write(data); + } + + onData(socket: Socket, data: Buffer): void { + this.bufferedData = this.bufferedData.length > 0 ? Buffer.concat([this.bufferedData, data]) : data; + + let messagesToDeliver: string[] = []; + + while (this.bufferedData.length > 0) { + if (this.state === FramerState.WaitingForLength) { + if (this.sizeBufferIndex + this.bufferedData.length < 4) { + const remainingBytes = Math.min(4 - this.sizeBufferIndex, this.bufferedData.length); + this.bufferedData.copy(this.sizeBuffer, this.sizeBufferIndex, 0, remainingBytes); + this.sizeBufferIndex += remainingBytes; + this.bufferedData = this.bufferedData.slice(remainingBytes); + break; + } + + const remainingBytes = 4 - this.sizeBufferIndex; + this.bufferedData.copy(this.sizeBuffer, this.sizeBufferIndex, 0, remainingBytes); + this.pendingLength = this.sizeBuffer.readUInt32BE(0); + + this.state = FramerState.WaitingForMessage; + this.sizeBufferIndex = 0; + this.bufferedData = this.bufferedData.slice(remainingBytes); + } + + if (this.bufferedData.length < this.pendingLength) { + break; + } + + const message = this.bufferedData.toString("utf-8", 0, this.pendingLength); + this.bufferedData = this.bufferedData.slice(this.pendingLength); + this.state = FramerState.WaitingForLength; + this.pendingLength = 0; + this.sizeBufferIndex = 0; + messagesToDeliver.push(message); + } + + for (const message of messagesToDeliver) { + this.onMessage(message); + } + } +}