mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
feat(node:inspector): implement Profiler API (#25939)
This commit is contained in:
@@ -65,6 +65,7 @@ pub const HardcodedModule = enum {
|
||||
@"node:trace_events",
|
||||
@"node:repl",
|
||||
@"node:inspector",
|
||||
@"node:inspector/promises",
|
||||
@"node:http2",
|
||||
@"node:diagnostics_channel",
|
||||
@"node:dgram",
|
||||
@@ -121,6 +122,7 @@ pub const HardcodedModule = enum {
|
||||
.{ "node:http2", .@"node:http2" },
|
||||
.{ "node:https", .@"node:https" },
|
||||
.{ "node:inspector", .@"node:inspector" },
|
||||
.{ "node:inspector/promises", .@"node:inspector/promises" },
|
||||
.{ "node:module", .@"node:module" },
|
||||
.{ "node:net", .@"node:net" },
|
||||
.{ "node:readline", .@"node:readline" },
|
||||
@@ -230,6 +232,7 @@ pub const HardcodedModule = enum {
|
||||
nodeEntry("node:http2"),
|
||||
nodeEntry("node:https"),
|
||||
nodeEntry("node:inspector"),
|
||||
nodeEntry("node:inspector/promises"),
|
||||
nodeEntry("node:module"),
|
||||
nodeEntry("node:net"),
|
||||
nodeEntry("node:os"),
|
||||
@@ -285,6 +288,7 @@ pub const HardcodedModule = enum {
|
||||
nodeEntry("http2"),
|
||||
nodeEntry("https"),
|
||||
nodeEntry("inspector"),
|
||||
nodeEntry("inspector/promises"),
|
||||
nodeEntry("module"),
|
||||
nodeEntry("net"),
|
||||
nodeEntry("os"),
|
||||
@@ -366,10 +370,6 @@ pub const HardcodedModule = enum {
|
||||
.{ "bun:internal-for-testing", .{ .path = "bun:internal-for-testing" } },
|
||||
.{ "ffi", .{ .path = "bun:ffi" } },
|
||||
|
||||
// inspector/promises is not implemented, it is an alias of inspector
|
||||
.{ "node:inspector/promises", .{ .path = "node:inspector", .node_builtin = true } },
|
||||
.{ "inspector/promises", .{ .path = "node:inspector", .node_builtin = true } },
|
||||
|
||||
// Thirdparty packages we override
|
||||
.{ "@vercel/fetch", .{ .path = "@vercel/fetch" } },
|
||||
.{ "isomorphic-fetch", .{ .path = "isomorphic-fetch" } },
|
||||
@@ -394,12 +394,7 @@ pub const HardcodedModule = enum {
|
||||
.{ "vitest", .{ .path = "bun:test" } },
|
||||
};
|
||||
|
||||
const node_extra_alias_kvs = [_]struct { string, Alias }{
|
||||
nodeEntry("node:inspector/promises"),
|
||||
nodeEntry("inspector/promises"),
|
||||
};
|
||||
|
||||
const node_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ node_extra_alias_kvs);
|
||||
const node_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs);
|
||||
pub const bun_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ bun_extra_alias_kvs);
|
||||
const bun_test_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ bun_extra_alias_kvs ++ bun_test_extra_alias_kvs);
|
||||
|
||||
|
||||
@@ -23,6 +23,19 @@ namespace Bun {
|
||||
|
||||
// Store the profiling start time in microseconds since Unix epoch
|
||||
static double s_profilingStartTime = 0.0;
|
||||
// Set sampling interval to 1ms (1000 microseconds) to match Node.js
|
||||
static int s_samplingInterval = 1000;
|
||||
static bool s_isProfilerRunning = false;
|
||||
|
||||
void setSamplingInterval(int intervalMicroseconds)
|
||||
{
|
||||
s_samplingInterval = intervalMicroseconds;
|
||||
}
|
||||
|
||||
bool isCPUProfilerRunning()
|
||||
{
|
||||
return s_isProfilerRunning;
|
||||
}
|
||||
|
||||
void startCPUProfiler(JSC::VM& vm)
|
||||
{
|
||||
@@ -35,12 +48,10 @@ void startCPUProfiler(JSC::VM& vm)
|
||||
stopwatch->start();
|
||||
|
||||
JSC::SamplingProfiler& samplingProfiler = vm.ensureSamplingProfiler(WTF::move(stopwatch));
|
||||
|
||||
// Set sampling interval to 1ms (1000 microseconds) to match Node.js
|
||||
samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(1000));
|
||||
|
||||
samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(s_samplingInterval));
|
||||
samplingProfiler.noticeCurrentThreadAsJSCExecutionThread();
|
||||
samplingProfiler.start();
|
||||
s_isProfilerRunning = true;
|
||||
}
|
||||
|
||||
struct ProfileNode {
|
||||
@@ -56,27 +67,29 @@ struct ProfileNode {
|
||||
|
||||
WTF::String stopCPUProfilerAndGetJSON(JSC::VM& vm)
|
||||
{
|
||||
s_isProfilerRunning = false;
|
||||
|
||||
JSC::SamplingProfiler* profiler = vm.samplingProfiler();
|
||||
if (!profiler)
|
||||
return WTF::String();
|
||||
|
||||
// Shut down the profiler thread first - this is critical!
|
||||
profiler->shutdown();
|
||||
|
||||
// Need to hold the VM lock to safely access stack traces
|
||||
// JSLock is re-entrant, so always acquiring it handles both JS and shutdown contexts
|
||||
JSC::JSLockHolder locker(vm);
|
||||
|
||||
// Defer GC while we're working with stack traces
|
||||
JSC::DeferGC deferGC(vm);
|
||||
|
||||
// Pause the profiler while holding the lock - this is critical for thread safety.
|
||||
// The sampling thread holds this lock while modifying traces, so holding it here
|
||||
// ensures no concurrent modifications. We use pause() instead of shutdown() to
|
||||
// allow the profiler to be restarted for the inspector API.
|
||||
auto& lock = profiler->getLock();
|
||||
WTF::Locker profilerLocker { lock };
|
||||
profiler->pause();
|
||||
|
||||
// releaseStackTraces() calls processUnverifiedStackTraces() internally
|
||||
auto stackTraces = profiler->releaseStackTraces();
|
||||
|
||||
if (stackTraces.isEmpty())
|
||||
return WTF::String();
|
||||
profiler->clearData();
|
||||
|
||||
// Build Chrome CPU Profiler format
|
||||
// Map from stack frame signature to node ID
|
||||
|
||||
@@ -10,11 +10,14 @@ class VM;
|
||||
|
||||
namespace Bun {
|
||||
|
||||
void setSamplingInterval(int intervalMicroseconds);
|
||||
bool isCPUProfilerRunning();
|
||||
|
||||
// Start the CPU profiler
|
||||
void startCPUProfiler(JSC::VM& vm);
|
||||
|
||||
// Stop the CPU profiler and convert to Chrome CPU profiler JSON format
|
||||
// Returns JSON string, or empty string on failure
|
||||
// Returns JSON string, or empty string if profiler was never started
|
||||
WTF::String stopCPUProfilerAndGetJSON(JSC::VM& vm);
|
||||
|
||||
} // namespace Bun
|
||||
|
||||
49
src/bun.js/bindings/JSInspectorProfiler.cpp
Normal file
49
src/bun.js/bindings/JSInspectorProfiler.cpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#include "root.h"
|
||||
#include "helpers.h"
|
||||
#include "BunCPUProfiler.h"
|
||||
#include "NodeValidator.h"
|
||||
#include <JavaScriptCore/JSGlobalObject.h>
|
||||
#include <JavaScriptCore/VM.h>
|
||||
#include <JavaScriptCore/Error.h>
|
||||
|
||||
using namespace JSC;
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_startCPUProfiler);
|
||||
JSC_DEFINE_HOST_FUNCTION(jsFunction_startCPUProfiler, (JSGlobalObject * globalObject, CallFrame*))
|
||||
{
|
||||
Bun::startCPUProfiler(globalObject->vm());
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_stopCPUProfiler);
|
||||
JSC_DEFINE_HOST_FUNCTION(jsFunction_stopCPUProfiler, (JSGlobalObject * globalObject, CallFrame*))
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto result = Bun::stopCPUProfilerAndGetJSON(vm);
|
||||
return JSValue::encode(jsString(vm, result));
|
||||
}
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval);
|
||||
JSC_DEFINE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval, (JSGlobalObject * globalObject, CallFrame* callFrame))
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto scope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
if (callFrame->argumentCount() < 1) {
|
||||
throwVMError(globalObject, scope, createNotEnoughArgumentsError(globalObject));
|
||||
return {};
|
||||
}
|
||||
|
||||
int interval;
|
||||
Bun::V::validateInteger(scope, globalObject, callFrame->uncheckedArgument(0), "interval"_s, jsNumber(1), jsUndefined(), &interval);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
Bun::setSamplingInterval(interval);
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning);
|
||||
JSC_DEFINE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning, (JSGlobalObject*, CallFrame*))
|
||||
{
|
||||
return JSValue::encode(jsBoolean(Bun::isCPUProfilerRunning()));
|
||||
}
|
||||
9
src/bun.js/bindings/JSInspectorProfiler.h
Normal file
9
src/bun.js/bindings/JSInspectorProfiler.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "root.h"
|
||||
#include <JavaScriptCore/JSCJSValue.h>
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_startCPUProfiler);
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_stopCPUProfiler);
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval);
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning);
|
||||
@@ -655,6 +655,10 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca
|
||||
if (sampleValue.isNumber()) {
|
||||
unsigned sampleInterval = sampleValue.toUInt32(globalObject);
|
||||
samplingProfiler.setTimingInterval(Seconds::fromMicroseconds(sampleInterval));
|
||||
} else {
|
||||
// Reset to default interval (1000 microseconds) to ensure each profile()
|
||||
// call is independent of previous calls
|
||||
samplingProfiler.setTimingInterval(Seconds::fromMicroseconds(1000));
|
||||
}
|
||||
|
||||
const auto report = [](JSC::VM& vm,
|
||||
@@ -670,7 +674,14 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca
|
||||
|
||||
JSValue stackTraces = JSONParse(globalObject, samplingProfiler.stackTracesAsJSON()->toJSONString());
|
||||
|
||||
samplingProfiler.shutdown();
|
||||
// Use pause() instead of shutdown() to allow the profiler to be restarted
|
||||
// shutdown() sets m_isShutDown=true which is never reset, making the profiler unusable
|
||||
{
|
||||
auto& lock = samplingProfiler.getLock();
|
||||
WTF::Locker locker { lock };
|
||||
samplingProfiler.pause();
|
||||
samplingProfiler.clearData();
|
||||
}
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 3);
|
||||
@@ -682,8 +693,9 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca
|
||||
};
|
||||
const auto reportFailure = [](JSC::VM& vm) -> JSC::JSValue {
|
||||
if (auto* samplingProfiler = vm.samplingProfiler()) {
|
||||
auto& lock = samplingProfiler->getLock();
|
||||
WTF::Locker locker { lock };
|
||||
samplingProfiler->pause();
|
||||
samplingProfiler->shutdown();
|
||||
samplingProfiler->clearData();
|
||||
}
|
||||
|
||||
@@ -706,8 +718,11 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca
|
||||
|
||||
JSNativeStdFunction* resolve = JSNativeStdFunction::create(
|
||||
vm, globalObject, 0, "resolve"_s,
|
||||
[report](JSGlobalObject* globalObject, CallFrame* callFrame) {
|
||||
return JSValue::encode(JSPromise::resolvedPromise(globalObject, report(globalObject->vm(), globalObject)));
|
||||
[report](JSGlobalObject* globalObject, CallFrame* callFrame) -> JSC::EncodedJSValue {
|
||||
auto scope = DECLARE_THROW_SCOPE(globalObject->vm());
|
||||
JSValue result = report(globalObject->vm(), globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::resolvedPromise(globalObject, result)));
|
||||
});
|
||||
JSNativeStdFunction* reject = JSNativeStdFunction::create(
|
||||
vm, globalObject, 0, "reject"_s,
|
||||
@@ -723,7 +738,8 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca
|
||||
return JSValue::encode(afterOngoingPromiseCapability);
|
||||
}
|
||||
|
||||
return JSValue::encode(report(vm, globalObject));
|
||||
JSValue result = report(vm, globalObject);
|
||||
RELEASE_AND_RETURN(throwScope, JSValue::encode(result));
|
||||
}
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(functionGenerateHeapSnapshotForDebugging);
|
||||
|
||||
28
src/js/node/inspector.promises.ts
Normal file
28
src/js/node/inspector.promises.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Hardcoded module "node:inspector/promises"
|
||||
const inspector = require("node:inspector");
|
||||
|
||||
const { Session: BaseSession, console, open, close, url, waitForDebugger } = inspector;
|
||||
|
||||
// Promise-based Session that wraps the callback-based Session
|
||||
class Session extends BaseSession {
|
||||
post(method: string, params?: object): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
super.post(method, params, (err: Error | null, result: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
console,
|
||||
open,
|
||||
close,
|
||||
url,
|
||||
waitForDebugger,
|
||||
Session,
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
// Hardcoded module "node:inspector" and "node:inspector/promises"
|
||||
// This is a stub! None of this is actually implemented yet.
|
||||
// Profiler APIs are implemented; other inspector APIs are stubs.
|
||||
const { hideFromStack, throwNotImplemented } = require("internal/shared");
|
||||
const EventEmitter = require("node:events");
|
||||
|
||||
// Native profiler functions exposed via $newCppFunction
|
||||
const startCPUProfiler = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_startCPUProfiler", 0);
|
||||
const stopCPUProfiler = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_stopCPUProfiler", 0);
|
||||
const setCPUSamplingInterval = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_setCPUSamplingInterval", 1);
|
||||
const isCPUProfilerRunning = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_isCPUProfilerRunning", 0);
|
||||
|
||||
function open() {
|
||||
throwNotImplemented("node:inspector", 2445);
|
||||
}
|
||||
@@ -22,9 +28,110 @@ function waitForDebugger() {
|
||||
}
|
||||
|
||||
class Session extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
throwNotImplemented("node:inspector", 2445);
|
||||
#connected = false;
|
||||
#profilerEnabled = false;
|
||||
|
||||
connect() {
|
||||
if (this.#connected) {
|
||||
throw new Error("Session is already connected");
|
||||
}
|
||||
this.#connected = true;
|
||||
}
|
||||
|
||||
connectToMainThread() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#connected) return;
|
||||
if (isCPUProfilerRunning()) stopCPUProfiler();
|
||||
this.#profilerEnabled = false;
|
||||
this.#connected = false;
|
||||
}
|
||||
|
||||
post(
|
||||
method: string,
|
||||
params?: object | ((err: Error | null, result?: any) => void),
|
||||
callback?: (err: Error | null, result?: any) => void,
|
||||
) {
|
||||
// Handle overloaded signature: post(method, callback)
|
||||
if (typeof params === "function") {
|
||||
callback = params;
|
||||
params = undefined;
|
||||
}
|
||||
|
||||
if (!this.#connected) {
|
||||
const error = new Error("Session is not connected");
|
||||
if (callback) {
|
||||
queueMicrotask(() => callback(error));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = this.#handleMethod(method, params as object | undefined);
|
||||
|
||||
if (callback) {
|
||||
// Callback API - async
|
||||
queueMicrotask(() => {
|
||||
if (result instanceof Error) {
|
||||
callback(result, undefined);
|
||||
} else {
|
||||
callback(null, result);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Sync throw for errors when no callback
|
||||
if (result instanceof Error) {
|
||||
throw result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
#handleMethod(method: string, params?: object): any {
|
||||
switch (method) {
|
||||
case "Profiler.enable":
|
||||
this.#profilerEnabled = true;
|
||||
return {};
|
||||
|
||||
case "Profiler.disable":
|
||||
if (isCPUProfilerRunning()) {
|
||||
stopCPUProfiler();
|
||||
}
|
||||
this.#profilerEnabled = false;
|
||||
return {};
|
||||
|
||||
case "Profiler.start":
|
||||
if (!this.#profilerEnabled) return new Error("Profiler is not enabled. Call Profiler.enable first.");
|
||||
if (!isCPUProfilerRunning()) startCPUProfiler();
|
||||
return {};
|
||||
|
||||
case "Profiler.stop":
|
||||
if (!isCPUProfilerRunning()) return new Error("Profiler is not started. Call Profiler.start first.");
|
||||
try {
|
||||
return { profile: JSON.parse(stopCPUProfiler()) };
|
||||
} catch (e) {
|
||||
return new Error(`Failed to parse profile JSON: ${e}`);
|
||||
}
|
||||
|
||||
case "Profiler.setSamplingInterval": {
|
||||
if (isCPUProfilerRunning()) return new Error("Cannot change sampling interval while profiler is running");
|
||||
const interval = (params as any)?.interval;
|
||||
if (typeof interval !== "number" || interval <= 0) return new Error("interval must be a positive number");
|
||||
setCPUSamplingInterval(interval);
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Profiler.getBestEffortCoverage":
|
||||
case "Profiler.startPreciseCoverage":
|
||||
case "Profiler.stopPreciseCoverage":
|
||||
case "Profiler.takePreciseCoverage":
|
||||
return new Error("Coverage APIs are not supported");
|
||||
|
||||
default:
|
||||
return new Error(`Inspector method "${method}" is not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,4 +183,34 @@ describe("bun:jsc", () => {
|
||||
const input = await promise;
|
||||
expect({ ...input }).toStrictEqual({ "0": 2 });
|
||||
});
|
||||
|
||||
it.todoIf(isBuildKite && isWindows)("profile can be called multiple times", () => {
|
||||
// Fibonacci generates deep stacks and is CPU-intensive
|
||||
function fib(n: number): number {
|
||||
if (n <= 1) return n;
|
||||
return fib(n - 1) + fib(n - 2);
|
||||
}
|
||||
|
||||
// First profile call
|
||||
const result1 = profile(() => fib(30));
|
||||
expect(result1).toBeDefined();
|
||||
expect(result1.functions).toBeDefined();
|
||||
expect(result1.stackTraces).toBeDefined();
|
||||
expect(result1.stackTraces.traces.length).toBeGreaterThan(0);
|
||||
|
||||
// Second profile call - should work after first one completed
|
||||
// This verifies that shutdown() -> pause() fix works
|
||||
const result2 = profile(() => fib(30));
|
||||
expect(result2).toBeDefined();
|
||||
expect(result2.functions).toBeDefined();
|
||||
expect(result2.stackTraces).toBeDefined();
|
||||
expect(result2.stackTraces.traces.length).toBeGreaterThan(0);
|
||||
|
||||
// Third profile call - verify profiler can be reused multiple times
|
||||
const result3 = profile(() => fib(30));
|
||||
expect(result3).toBeDefined();
|
||||
expect(result3.functions).toBeDefined();
|
||||
expect(result3.stackTraces).toBeDefined();
|
||||
expect(result3.stackTraces.traces.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
402
test/js/node/inspector/inspector-profiler.test.ts
Normal file
402
test/js/node/inspector/inspector-profiler.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import inspector from "node:inspector";
|
||||
import inspectorPromises from "node:inspector/promises";
|
||||
|
||||
describe("node:inspector", () => {
|
||||
describe("Session", () => {
|
||||
let session: inspector.Session;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new inspector.Session();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
session.disconnect();
|
||||
} catch {
|
||||
// Ignore if already disconnected
|
||||
}
|
||||
});
|
||||
|
||||
test("Session is a constructor", () => {
|
||||
expect(inspector.Session).toBeInstanceOf(Function);
|
||||
expect(session).toBeInstanceOf(inspector.Session);
|
||||
});
|
||||
|
||||
test("Session extends EventEmitter", () => {
|
||||
expect(typeof session.on).toBe("function");
|
||||
expect(typeof session.emit).toBe("function");
|
||||
expect(typeof session.removeListener).toBe("function");
|
||||
});
|
||||
|
||||
test("connect() establishes connection", () => {
|
||||
expect(() => session.connect()).not.toThrow();
|
||||
});
|
||||
|
||||
test("connect() throws if already connected", () => {
|
||||
session.connect();
|
||||
expect(() => session.connect()).toThrow("already connected");
|
||||
});
|
||||
|
||||
test("connectToMainThread() works like connect()", () => {
|
||||
expect(() => session.connectToMainThread()).not.toThrow();
|
||||
});
|
||||
|
||||
test("disconnect() closes connection cleanly", () => {
|
||||
session.connect();
|
||||
expect(() => session.disconnect()).not.toThrow();
|
||||
});
|
||||
|
||||
test("disconnect() is a no-op if not connected", () => {
|
||||
expect(() => session.disconnect()).not.toThrow();
|
||||
});
|
||||
|
||||
test("post() throws if not connected", () => {
|
||||
expect(() => session.post("Profiler.enable")).toThrow("not connected");
|
||||
});
|
||||
|
||||
test("post() with callback calls callback with error if not connected", async () => {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<Error>();
|
||||
session.post("Profiler.enable", err => {
|
||||
if (err) resolve(err);
|
||||
else reject(new Error("Expected error"));
|
||||
});
|
||||
const error = await promise;
|
||||
expect(error.message).toContain("not connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profiler", () => {
|
||||
let session: inspector.Session;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new inspector.Session();
|
||||
session.connect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
session.disconnect();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
test("Profiler.enable succeeds", () => {
|
||||
const result = session.post("Profiler.enable");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("Profiler.disable succeeds", () => {
|
||||
session.post("Profiler.enable");
|
||||
const result = session.post("Profiler.disable");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("Profiler.start without enable throws", () => {
|
||||
expect(() => session.post("Profiler.start")).toThrow("not enabled");
|
||||
});
|
||||
|
||||
test("Profiler.start after enable succeeds", () => {
|
||||
session.post("Profiler.enable");
|
||||
const result = session.post("Profiler.start");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("Profiler.stop without start throws", () => {
|
||||
session.post("Profiler.enable");
|
||||
expect(() => session.post("Profiler.stop")).toThrow("not started");
|
||||
});
|
||||
|
||||
test("Profiler.stop returns valid profile", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
|
||||
// Do some work to generate profile data
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
sum += Math.sqrt(i);
|
||||
}
|
||||
|
||||
const result = session.post("Profiler.stop");
|
||||
|
||||
expect(result).toHaveProperty("profile");
|
||||
const profile = result.profile;
|
||||
|
||||
// Validate profile structure
|
||||
expect(profile).toHaveProperty("nodes");
|
||||
expect(profile).toHaveProperty("startTime");
|
||||
expect(profile).toHaveProperty("endTime");
|
||||
expect(profile).toHaveProperty("samples");
|
||||
expect(profile).toHaveProperty("timeDeltas");
|
||||
|
||||
expect(profile.nodes).toBeArray();
|
||||
expect(profile.nodes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// First node should be (root)
|
||||
const rootNode = profile.nodes[0];
|
||||
expect(rootNode).toHaveProperty("id", 1);
|
||||
expect(rootNode).toHaveProperty("callFrame");
|
||||
expect(rootNode.callFrame).toHaveProperty("functionName", "(root)");
|
||||
expect(rootNode.callFrame).toHaveProperty("scriptId", "0");
|
||||
expect(rootNode.callFrame).toHaveProperty("url", "");
|
||||
expect(rootNode.callFrame).toHaveProperty("lineNumber", -1);
|
||||
expect(rootNode.callFrame).toHaveProperty("columnNumber", -1);
|
||||
});
|
||||
|
||||
test("complete enable->start->stop workflow", () => {
|
||||
// Enable profiler
|
||||
const enableResult = session.post("Profiler.enable");
|
||||
expect(enableResult).toEqual({});
|
||||
|
||||
// Start profiling
|
||||
const startResult = session.post("Profiler.start");
|
||||
expect(startResult).toEqual({});
|
||||
|
||||
// Do some work
|
||||
function fibonacci(n: number): number {
|
||||
if (n <= 1) return n;
|
||||
return fibonacci(n - 1) + fibonacci(n - 2);
|
||||
}
|
||||
fibonacci(20);
|
||||
|
||||
// Stop profiling
|
||||
const stopResult = session.post("Profiler.stop");
|
||||
expect(stopResult).toHaveProperty("profile");
|
||||
|
||||
// Disable profiler
|
||||
const disableResult = session.post("Profiler.disable");
|
||||
expect(disableResult).toEqual({});
|
||||
});
|
||||
|
||||
test("samples and timeDeltas have same length", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
|
||||
// Do some work
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
sum += Math.sqrt(i);
|
||||
}
|
||||
|
||||
const result = session.post("Profiler.stop");
|
||||
const profile = result.profile;
|
||||
|
||||
expect(profile.samples.length).toBe(profile.timeDeltas.length);
|
||||
});
|
||||
|
||||
test("samples reference valid node IDs", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
|
||||
// Do some work
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
sum += Math.sqrt(i);
|
||||
}
|
||||
|
||||
const result = session.post("Profiler.stop");
|
||||
const profile = result.profile;
|
||||
|
||||
const nodeIds = new Set(profile.nodes.map((n: any) => n.id));
|
||||
for (const sample of profile.samples) {
|
||||
expect(nodeIds.has(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("Profiler.setSamplingInterval works", () => {
|
||||
session.post("Profiler.enable");
|
||||
const result = session.post("Profiler.setSamplingInterval", { interval: 500 });
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("Profiler.setSamplingInterval throws if profiler is running", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
expect(() => session.post("Profiler.setSamplingInterval", { interval: 500 })).toThrow(
|
||||
"Cannot change sampling interval while profiler is running",
|
||||
);
|
||||
session.post("Profiler.stop");
|
||||
});
|
||||
|
||||
test("Profiler.setSamplingInterval requires positive interval", () => {
|
||||
session.post("Profiler.enable");
|
||||
expect(() => session.post("Profiler.setSamplingInterval", { interval: 0 })).toThrow();
|
||||
expect(() => session.post("Profiler.setSamplingInterval", { interval: -1 })).toThrow();
|
||||
});
|
||||
|
||||
test("double Profiler.start is a no-op", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
const result = session.post("Profiler.start");
|
||||
expect(result).toEqual({});
|
||||
session.post("Profiler.stop");
|
||||
});
|
||||
|
||||
test("profiler can be restarted after stop", () => {
|
||||
// First run
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 1000; i++) sum += i;
|
||||
const result1 = session.post("Profiler.stop");
|
||||
expect(result1).toHaveProperty("profile");
|
||||
|
||||
// Second run
|
||||
session.post("Profiler.start");
|
||||
for (let i = 0; i < 1000; i++) sum += i;
|
||||
const result2 = session.post("Profiler.stop");
|
||||
expect(result2).toHaveProperty("profile");
|
||||
|
||||
// Both profiles should be valid
|
||||
expect(result1.profile.nodes.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result2.profile.nodes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("disconnect() stops running profiler", () => {
|
||||
session.post("Profiler.enable");
|
||||
session.post("Profiler.start");
|
||||
session.disconnect();
|
||||
|
||||
// Create new session and verify profiler was stopped
|
||||
const session2 = new inspector.Session();
|
||||
session2.connect();
|
||||
session2.post("Profiler.enable");
|
||||
|
||||
// This should work without error (profiler is not running)
|
||||
const result = session2.post("Profiler.setSamplingInterval", { interval: 500 });
|
||||
expect(result).toEqual({});
|
||||
session2.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("callback API", () => {
|
||||
test("post() with callback receives result", async () => {
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<any>();
|
||||
session.post("Profiler.enable", (err, result) => {
|
||||
resolve({ err, result });
|
||||
});
|
||||
|
||||
const { err, result } = await promise;
|
||||
expect(err).toBeNull();
|
||||
expect(result).toEqual({});
|
||||
session.disconnect();
|
||||
});
|
||||
|
||||
test("post() with callback receives error", async () => {
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<any>();
|
||||
session.post("Profiler.start", (err, result) => {
|
||||
resolve({ err, result });
|
||||
});
|
||||
|
||||
const { err, result } = await promise;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("not enabled");
|
||||
session.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported methods", () => {
|
||||
test("unsupported method throws", () => {
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
expect(() => session.post("Runtime.evaluate")).toThrow("not supported");
|
||||
session.disconnect();
|
||||
});
|
||||
|
||||
test("coverage APIs throw not supported", () => {
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
session.post("Profiler.enable");
|
||||
expect(() => session.post("Profiler.getBestEffortCoverage")).toThrow("not supported");
|
||||
expect(() => session.post("Profiler.startPreciseCoverage")).toThrow("not supported");
|
||||
expect(() => session.post("Profiler.stopPreciseCoverage")).toThrow("not supported");
|
||||
expect(() => session.post("Profiler.takePreciseCoverage")).toThrow("not supported");
|
||||
session.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exports", () => {
|
||||
test("url() returns undefined", () => {
|
||||
expect(inspector.url()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("console is exported", () => {
|
||||
expect(inspector.console).toBeObject();
|
||||
expect(inspector.console.log).toBe(globalThis.console.log);
|
||||
});
|
||||
|
||||
test("open() throws not implemented", () => {
|
||||
expect(() => inspector.open()).toThrow();
|
||||
});
|
||||
|
||||
test("close() throws not implemented", () => {
|
||||
expect(() => inspector.close()).toThrow();
|
||||
});
|
||||
|
||||
test("waitForDebugger() throws not implemented", () => {
|
||||
expect(() => inspector.waitForDebugger()).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("node:inspector/promises", () => {
|
||||
test("Session is exported", () => {
|
||||
expect(inspectorPromises.Session).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("post() returns a Promise", async () => {
|
||||
const session = new inspectorPromises.Session();
|
||||
session.connect();
|
||||
|
||||
const result = session.post("Profiler.enable");
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
|
||||
await expect(result).resolves.toEqual({});
|
||||
session.disconnect();
|
||||
});
|
||||
|
||||
test("post() rejects on error", async () => {
|
||||
const session = new inspectorPromises.Session();
|
||||
session.connect();
|
||||
|
||||
await expect(session.post("Profiler.start")).rejects.toThrow("not enabled");
|
||||
session.disconnect();
|
||||
});
|
||||
|
||||
test("complete profiling workflow with promises", async () => {
|
||||
const session = new inspectorPromises.Session();
|
||||
session.connect();
|
||||
|
||||
await session.post("Profiler.enable");
|
||||
await session.post("Profiler.start");
|
||||
|
||||
// Do some work
|
||||
function work(n: number): number {
|
||||
if (n <= 1) return n;
|
||||
return work(n - 1) + work(n - 2);
|
||||
}
|
||||
work(15);
|
||||
|
||||
const result = await session.post("Profiler.stop");
|
||||
expect(result).toHaveProperty("profile");
|
||||
expect(result.profile.nodes).toBeArray();
|
||||
|
||||
await session.post("Profiler.disable");
|
||||
session.disconnect();
|
||||
});
|
||||
|
||||
test("other exports are the same as node:inspector", () => {
|
||||
expect(inspectorPromises.url).toBe(inspector.url);
|
||||
expect(inspectorPromises.console).toBe(inspector.console);
|
||||
expect(inspectorPromises.open).toBe(inspector.open);
|
||||
expect(inspectorPromises.close).toBe(inspector.close);
|
||||
expect(inspectorPromises.waitForDebugger).toBe(inspector.waitForDebugger);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user