Compare commits

...

9 Commits

Author SHA1 Message Date
Jarred Sumner
f46e3f6841 Add inspecto test 2024-11-17 07:43:25 -08:00
Jarred Sumner
74457e311c Update test_command.zig 2024-11-17 07:16:12 -08:00
Jarred Sumner
189bda01ed Update javascript.zig 2024-11-17 07:16:05 -08:00
Jarred Sumner
f28b262b5c Update BunDebugger.cpp 2024-11-17 07:16:00 -08:00
Jarred Sumner
f2ec92e1e7 okay it works now 2024-11-17 07:00:25 -08:00
Jarred Sumner
080bfd9f62 Update .gitignore 2024-11-17 06:55:16 -08:00
Jarred Sumner
8d7593271b [Inspector] Support TCP sockets, unix domain sockets, report stacktraced exceptions 2024-11-17 03:48:47 -08:00
Jarred Sumner
8285650928 Fix crash when coverage is enabled in certain cases 2024-11-17 03:25:17 -08:00
Jarred Sumner
7ca95b1ed8 Introduce InspectorLifecycleAgent and InspectorTestReporterAgent 2024-11-16 21:05:09 -08:00
25 changed files with 3133 additions and 2584 deletions

20
.gitignore vendored
View File

@@ -26,6 +26,7 @@
*.db
*.dmg
*.dSYM
*.generated.ts
*.jsb
*.lib
*.log
@@ -53,8 +54,8 @@
/test-report.md
/test.js
/test.ts
/testdir
/test.zig
/testdir
build
build.ninja
bun-binary
@@ -111,8 +112,10 @@ pnpm-lock.yaml
profile.json
README.md.template
release/
scripts/env.local
sign.*.json
sign.json
src/bake/generated.ts
src/bun.js/bindings-obj
src/bun.js/bindings/GeneratedJS2Native.zig
src/bun.js/debug-bindings-obj
@@ -131,17 +134,13 @@ src/runtime.version
src/tests.zig
test.txt
test/js/bun/glob/fixtures
test/node.js/upstream
tsconfig.tsbuildinfo
txt.js
x64
yarn.lock
zig-cache
zig-out
test/node.js/upstream
.zig-cache
scripts/env.local
*.generated.ts
src/bake/generated.ts
# Dependencies
/vendor
@@ -149,22 +148,23 @@ src/bake/generated.ts
# Dependencies (before CMake)
# These can be removed in the far future
/src/bun.js/WebKit
/src/deps/WebKit
/src/deps/boringssl
/src/deps/brotli
/src/deps/c*ares
/src/deps/lol*html
/src/deps/libarchive
/src/deps/libdeflate
/src/deps/libuv
/src/deps/lol*html
/src/deps/ls*hpack
/src/deps/mimalloc
/src/deps/picohttpparser
/src/deps/tinycc
/src/deps/zstd
/src/deps/zlib
/src/deps/WebKit
/src/deps/zig
/src/deps/zlib
/src/deps/zstd
# Generated files
.buildkite/ci.yml
*.sock

View File

View File

@@ -2,7 +2,7 @@ option(WEBKIT_VERSION "The version of WebKit to use")
option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of downloading")
if(NOT WEBKIT_VERSION)
set(WEBKIT_VERSION 3bc4abf2d5875baf500b4687ef869987f6d19e00)
set(WEBKIT_VERSION 8f9ae4f01a047c666ef548864294e01df731d4ea)
endif()
if(WEBKIT_LOCAL)

View File

@@ -1,26 +1,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { readFileSync, writeFileSync, realpathSync } from "node:fs";
import type { Domain, Property, Protocol } from "../src/protocol/schema";
run().catch(console.error);
async function run() {
const cwd = new URL("../src/protocol/", import.meta.url);
const runner = "Bun" in globalThis ? "bunx" : "npx";
const write = (name: string, data: string) => {
const path = new URL(name, cwd);
writeFileSync(path, data);
spawnSync(runner, ["prettier", "--write", path.pathname], { cwd, stdio: "ignore" });
};
const base = readFileSync(new URL("protocol.d.ts", cwd), "utf-8");
const baseNoComments = base.replace(/\/\/.*/g, "");
const jsc = await downloadJsc();
write("jsc/protocol.json", JSON.stringify(jsc));
write("jsc/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(jsc, baseNoComments));
const v8 = await downloadV8();
write("v8/protocol.json", JSON.stringify(v8));
write("v8/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(v8, baseNoComments));
}
import path from "node:path";
function formatProtocol(protocol: Protocol, extraTs?: string): string {
const { name, domains } = protocol;
@@ -29,6 +10,7 @@ function formatProtocol(protocol: Protocol, extraTs?: string): string {
let body = `export namespace ${name} {`;
for (const { domain, types = [], events = [], commands = [] } of domains) {
body += `export namespace ${domain} {`;
for (const type of types) {
body += formatProperty(type);
}
@@ -153,32 +135,12 @@ async function downloadV8(): Promise<Protocol> {
}));
}
/**
* @link https://github.com/WebKit/WebKit/tree/main/Source/JavaScriptCore/inspector/protocol
*/
async function downloadJsc(): Promise<Protocol> {
const baseUrl = "https://raw.githubusercontent.com/WebKit/WebKit/main/Source/JavaScriptCore/inspector/protocol";
const domains = [
"Runtime",
"Console",
"Debugger",
"Heap",
"ScriptProfiler",
"CPUProfiler",
"GenericTypes",
"Network",
"Inspector",
];
return {
name: "JSC",
version: {
major: 1,
minor: 3,
},
domains: await Promise.all(domains.map(domain => download<Domain>(`${baseUrl}/${domain}.json`))).then(domains =>
domains.sort((a, b) => a.domain.localeCompare(b.domain)),
),
};
async function getJSC(): Promise<Protocol> {
let bunExecutable = Bun.which("bun-debug") || process.execPath;
if (!bunExecutable) {
throw new Error("bun-debug not found");
}
bunExecutable = realpathSync(bunExecutable);
}
async function download<V>(url: string): Promise<V> {
@@ -200,3 +162,39 @@ function toComment(description?: string): string {
const lines = ["/**", ...description.split("\n").map(line => ` * ${line.trim()}`), "*/"];
return lines.join("\n");
}
const cwd = new URL("../src/protocol/", import.meta.url);
const runner = "Bun" in globalThis ? "bunx" : "npx";
const write = (name: string, data: string) => {
const filePath = path.resolve(__dirname, "..", "src", "protocol", name);
writeFileSync(filePath, data);
spawnSync(runner, ["prettier", "--write", filePath], { cwd, stdio: "ignore" });
};
const base = readFileSync(new URL("protocol.d.ts", cwd), "utf-8");
const baseNoComments = base.replace(/\/\/.*/g, "");
const jscJsonFile = path.resolve(__dirname, process.argv.at(-1) ?? "");
let jscJSONFile;
try {
jscJSONFile = await Bun.file(jscJsonFile).json();
} catch (error) {
console.warn("Failed to read CombinedDomains.json from WebKit build. Is this a WebKit build from Bun?");
console.error(error);
process.exit(1);
}
const jsc = {
name: "JSC",
version: {
major: 1,
minor: 4,
},
domains: jscJSONFile.domains
.filter(a => a.debuggableTypes?.includes?.("javascript"))
.sort((a, b) => a.domain.localeCompare(b.domain)),
};
write("jsc/protocol.json", JSON.stringify(jsc, null, 2));
write("jsc/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(jsc, baseNoComments));
const v8 = await downloadV8();
write("v8/protocol.json", JSON.stringify(v8));
write("v8/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(v8, baseNoComments));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4534,6 +4534,11 @@ declare module "bun" {
unix: string;
}
interface FdSocketOptions<Data = undefined> extends SocketOptions<Data> {
tls?: TLSOptions;
fd: number;
}
/**
* Create a TCP client that connects to a server
*

View File

@@ -12,6 +12,9 @@
#include "BunInjectedScriptHost.h"
#include <JavaScriptCore/JSGlobalObjectInspectorController.h>
#include "InspectorLifecycleAgent.h"
#include "InspectorTestReporterAgent.h"
extern "C" void Bun__tickWhilePaused(bool*);
extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta);
@@ -51,10 +54,6 @@ public:
}
void unpauseForInitializedInspector() override
{
if (waitingForConnection) {
waitingForConnection = false;
Debugger__didConnect();
}
}
};
@@ -100,6 +99,17 @@ public:
globalObject->setInspectable(true);
auto& inspector = globalObject->inspectorDebuggable();
inspector.setInspectable(true);
static bool hasConnected = false;
if (!hasConnected) {
hasConnected = true;
globalObject->inspectorController().registerAlternateAgent(
WTF::makeUnique<Inspector::InspectorLifecycleAgent>(*globalObject));
globalObject->inspectorController().registerAlternateAgent(
WTF::makeUnique<Inspector::InspectorTestReporterAgent>(*globalObject));
}
globalObject->inspectorController().connectFrontend(*this, true, false); // waitingForConnection
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
@@ -109,6 +119,11 @@ public:
};
}
if (waitingForConnection) {
waitingForConnection = false;
Debugger__didConnect();
}
this->receiveMessagesOnInspectorThread(context, reinterpret_cast<Zig::GlobalObject*>(globalObject), false);
}
@@ -313,6 +328,22 @@ public:
}
}
void sendMessageToInspectorFromDebuggerThread(Vector<WTF::String, 12>&& inputMessages)
{
{
Locker<Lock> locker(jsThreadMessagesLock);
jsThreadMessages.appendVector(inputMessages);
}
if (this->jsWaitForMessageFromInspectorLock.isLocked()) {
this->jsWaitForMessageFromInspectorLock.unlock();
} else if (this->jsThreadMessageScheduledCount++ == 0) {
ScriptExecutionContext::postTaskTo(scriptExecutionContextIdentifier, [connection = this](ScriptExecutionContext& context) {
connection->receiveMessagesOnInspectorThread(context, reinterpret_cast<Zig::GlobalObject*>(context.jsGlobalObject()), true);
});
}
}
void sendMessageToInspectorFromDebuggerThread(const WTF::String& inputMessage)
{
{
@@ -345,6 +376,8 @@ public:
std::atomic<ConnectionStatus> status = ConnectionStatus::Pending;
bool unrefOnDisconnect = false;
bool hasEverConnected = false;
};
JSC_DECLARE_HOST_FUNCTION(jsFunctionSend);
@@ -404,12 +437,22 @@ private:
JSC_DEFINE_HOST_FUNCTION(jsFunctionSend, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto* jsConnection = jsDynamicCast<JSBunInspectorConnection*>(callFrame->thisValue());
auto message = callFrame->uncheckedArgument(0).toWTFString(globalObject).isolatedCopy();
auto message = callFrame->uncheckedArgument(0);
if (!jsConnection)
return JSValue::encode(jsUndefined());
jsConnection->connection()->sendMessageToInspectorFromDebuggerThread(message);
if (message.isString()) {
jsConnection->connection()->sendMessageToInspectorFromDebuggerThread(message.toWTFString(globalObject).isolatedCopy());
} else if (message.isCell()) {
auto* array = jsCast<JSArray*>(message.asCell());
Vector<WTF::String, 12> messages;
JSC::forEachInArrayLike(globalObject, array, [&](JSC::JSValue value) -> bool {
messages.append(value.toWTFString(globalObject).isolatedCopy());
return true;
});
jsConnection->connection()->sendMessageToInspectorFromDebuggerThread(WTFMove(messages));
}
return JSValue::encode(jsUndefined());
}
@@ -519,12 +562,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionCreateConnection, (JSGlobalObject * globalObj
return JSValue::encode(JSBunInspectorConnection::create(vm, JSBunInspectorConnection::createStructure(vm, globalObject, globalObject->objectPrototype()), connection));
}
extern "C" void Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGlobalObject, ScriptExecutionContextIdentifier scriptId, BunString* portOrPathString)
extern "C" void Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGlobalObject, ScriptExecutionContextIdentifier scriptId, BunString* portOrPathString, int isAutomatic)
{
if (!debuggerScriptExecutionContext)
debuggerScriptExecutionContext = debuggerGlobalObject->scriptExecutionContext();
JSC::VM& vm = debuggerGlobalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue defaultValue = debuggerGlobalObject->internalModuleRegistry()->requireId(debuggerGlobalObject, vm, InternalModuleRegistry::Field::InternalDebugger);
scope.assertNoException();
JSFunction* debuggerDefaultFn = jsCast<JSFunction*>(defaultValue.asCell());
MarkedArgumentBuffer arguments;
@@ -534,8 +580,9 @@ extern "C" void Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGlobalObje
arguments.append(JSFunction::create(vm, debuggerGlobalObject, 3, String(), jsFunctionCreateConnection, ImplementationVisibility::Public));
arguments.append(JSFunction::create(vm, debuggerGlobalObject, 1, String("send"_s), jsFunctionSend, ImplementationVisibility::Public));
arguments.append(JSFunction::create(vm, debuggerGlobalObject, 0, String("disconnect"_s), jsFunctionDisconnect, ImplementationVisibility::Public));
arguments.append(jsBoolean(isAutomatic));
JSC::call(debuggerGlobalObject, debuggerDefaultFn, arguments, "Bun__initJSDebuggerThread - debuggerDefaultFn"_s);
scope.assertNoException();
}
enum class AsyncCallTypeUint8 : uint8_t {

View File

@@ -6,6 +6,8 @@
#include <wtf/Vector.h>
#include <wtf/text/WTFString.h>
#include <JavaScriptCore/InspectorConsoleAgent.h>
namespace Inspector {
class InspectorConsoleAgent;
class InspectorDebuggerAgent;
@@ -31,6 +33,7 @@ public:
static bool logToSystemConsole();
static void setLogToSystemConsole(bool);
Inspector::InspectorConsoleAgent* consoleAgent() { return m_consoleAgent; }
void setDebuggerAgent(InspectorDebuggerAgent* agent) { m_debuggerAgent = agent; }
void setPersistentScriptProfilerAgent(InspectorScriptProfilerAgent* agent)
{

View File

@@ -36,7 +36,7 @@ static ImplementationVisibility getImplementationVisibility(JSC::CodeBlock* code
return ImplementationVisibility::Public;
}
static bool isImplementationVisibilityPrivate(JSC::StackVisitor& visitor)
bool isImplementationVisibilityPrivate(JSC::StackVisitor& visitor)
{
ImplementationVisibility implementationVisibility = [&]() -> ImplementationVisibility {
if (visitor->callee().isCell()) {
@@ -63,7 +63,7 @@ static bool isImplementationVisibilityPrivate(JSC::StackVisitor& visitor)
return implementationVisibility != ImplementationVisibility::Public;
}
static bool isImplementationVisibilityPrivate(const JSC::StackFrame& frame)
bool isImplementationVisibilityPrivate(const JSC::StackFrame& frame)
{
ImplementationVisibility implementationVisibility = [&]() -> ImplementationVisibility {

View File

@@ -211,4 +211,6 @@ private:
}
};
bool isImplementationVisibilityPrivate(JSC::StackVisitor& visitor);
bool isImplementationVisibilityPrivate(const JSC::StackFrame& frame);
}

View File

@@ -0,0 +1,130 @@
#include "InspectorLifecycleAgent.h"
#include <JavaScriptCore/InspectorFrontendRouter.h>
#include <JavaScriptCore/InspectorBackendDispatcher.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <wtf/text/WTFString.h>
#include <JavaScriptCore/ScriptCallStackFactory.h>
#include <JavaScriptCore/ScriptArguments.h>
#include <JavaScriptCore/ConsoleMessage.h>
#include <JavaScriptCore/InspectorConsoleAgent.h>
#include <JavaScriptCore/JSGlobalObjectDebuggable.h>
#include <JavaScriptCore/JSGlobalObjectInspectorController.h>
#include "ConsoleObject.h"
namespace Inspector {
// Zig bindings implementation
extern "C" {
void Bun__LifecycleAgentEnable(Inspector::InspectorLifecycleAgent* agent);
void Bun__LifecycleAgentDisable(Inspector::InspectorLifecycleAgent* agent);
void Bun__LifecycleAgentReportReload(Inspector::InspectorLifecycleAgent* agent)
{
agent->reportReload();
}
void Bun__LifecycleAgentReportError(Inspector::InspectorLifecycleAgent* agent, ZigException* exception)
{
ASSERT(exception);
ASSERT(agent);
agent->reportError(*exception);
}
void Bun__LifecycleAgentPreventExit(Inspector::InspectorLifecycleAgent* agent);
void Bun__LifecycleAgentStopPreventingExit(Inspector::InspectorLifecycleAgent* agent);
}
InspectorLifecycleAgent::InspectorLifecycleAgent(JSC::JSGlobalObject& globalObject)
: InspectorAgentBase("LifecycleReporter"_s)
, m_globalObject(globalObject)
, m_backendDispatcher(LifecycleReporterBackendDispatcher::create(m_globalObject.inspectorController().backendDispatcher(), this))
, m_frontendDispatcher(makeUnique<LifecycleReporterFrontendDispatcher>(const_cast<FrontendRouter&>(m_globalObject.inspectorController().frontendRouter())))
{
}
InspectorLifecycleAgent::~InspectorLifecycleAgent()
{
if (m_enabled) {
Bun__LifecycleAgentDisable(this);
}
}
void InspectorLifecycleAgent::didCreateFrontendAndBackend(FrontendRouter*, BackendDispatcher*)
{
}
void InspectorLifecycleAgent::willDestroyFrontendAndBackend(DisconnectReason)
{
disable();
}
Protocol::ErrorStringOr<void> InspectorLifecycleAgent::enable()
{
if (m_enabled)
return {};
m_enabled = true;
Bun__LifecycleAgentEnable(this);
return {};
}
Protocol::ErrorStringOr<void> InspectorLifecycleAgent::disable()
{
if (!m_enabled)
return {};
m_enabled = false;
Bun__LifecycleAgentDisable(this);
return {};
}
void InspectorLifecycleAgent::reportReload()
{
if (!m_enabled)
return;
m_frontendDispatcher->reload();
}
void InspectorLifecycleAgent::reportError(ZigException& exception)
{
if (!m_enabled)
return;
String message = exception.message.toWTFString();
String name = exception.name.toWTFString();
Ref<JSON::ArrayOf<String>> urls = JSON::ArrayOf<String>::create();
Ref<JSON::ArrayOf<int>> lineColumns = JSON::ArrayOf<int>::create();
Ref<JSON::ArrayOf<String>> sourceLines = JSON::ArrayOf<String>::create();
for (size_t i = 0; i < exception.stack.source_lines_len; i++) {
sourceLines->addItem(exception.stack.source_lines_ptr[i].toWTFString());
}
for (size_t i = 0; i < exception.stack.frames_len; i++) {
ZigStackFrame* frame = &exception.stack.frames_ptr[i];
lineColumns->addItem(frame->position.line_zero_based + 1);
lineColumns->addItem(frame->position.column_zero_based + 1);
}
// error(const String& message, const String& name, Ref<JSON::ArrayOf<String>>&& urls, Ref<JSON::ArrayOf<int>>&& lineColumns, Ref<JSON::ArrayOf<String>>&& sourceLines);
m_frontendDispatcher->error(WTFMove(message), WTFMove(name), WTFMove(urls), WTFMove(lineColumns), WTFMove(sourceLines));
}
Protocol::ErrorStringOr<void> InspectorLifecycleAgent::preventExit()
{
m_preventingExit = true;
return {};
}
Protocol::ErrorStringOr<void> InspectorLifecycleAgent::stopPreventingExit()
{
m_preventingExit = false;
return {};
}
} // namespace Inspector

View File

@@ -0,0 +1,48 @@
#pragma once
#include "root.h"
#include <JavaScriptCore/AlternateDispatchableAgent.h>
#include <JavaScriptCore/InspectorAgentBase.h>
#include <JavaScriptCore/InspectorBackendDispatchers.h>
#include <JavaScriptCore/InspectorFrontendDispatchers.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <wtf/Forward.h>
#include <wtf/Noncopyable.h>
#include "headers-handwritten.h"
namespace Inspector {
class FrontendRouter;
class BackendDispatcher;
class LifecycleReporterFrontendDispatcher;
enum class DisconnectReason;
class InspectorLifecycleAgent final : public InspectorAgentBase, public Inspector::LifecycleReporterBackendDispatcherHandler {
WTF_MAKE_NONCOPYABLE(InspectorLifecycleAgent);
public:
InspectorLifecycleAgent(JSC::JSGlobalObject&);
virtual ~InspectorLifecycleAgent();
// InspectorAgentBase
virtual void didCreateFrontendAndBackend(FrontendRouter*, BackendDispatcher*) final;
virtual void willDestroyFrontendAndBackend(DisconnectReason) final;
// LifecycleReporterBackendDispatcherHandler
virtual Protocol::ErrorStringOr<void> enable() final;
virtual Protocol::ErrorStringOr<void> disable() final;
// Public API
void reportReload();
void reportError(ZigException&);
Protocol::ErrorStringOr<void> preventExit();
Protocol::ErrorStringOr<void> stopPreventingExit();
private:
JSC::JSGlobalObject& m_globalObject;
std::unique_ptr<LifecycleReporterFrontendDispatcher> m_frontendDispatcher;
Ref<LifecycleReporterBackendDispatcher> m_backendDispatcher;
bool m_enabled { false };
bool m_preventingExit { false };
};
} // namespace Inspector

View File

@@ -0,0 +1,210 @@
#include "InspectorTestReporterAgent.h"
#include <JavaScriptCore/InspectorFrontendRouter.h>
#include <JavaScriptCore/InspectorBackendDispatcher.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <wtf/text/WTFString.h>
#include <JavaScriptCore/ScriptCallStack.h>
#include <JavaScriptCore/JSGlobalObjectDebuggable.h>
#include <JavaScriptCore/JSGlobalObjectInspectorController.h>
#include "ErrorStackTrace.h"
#include "ZigGlobalObject.h"
#include "ModuleLoader.h"
namespace Inspector {
// Zig bindings implementation
extern "C" {
void Bun__TestReporterAgentEnable(Inspector::InspectorTestReporterAgent* agent);
void Bun__TestReporterAgentDisable(Inspector::InspectorTestReporterAgent* agent);
void Bun__TestReporterAgentReportTestFound(Inspector::InspectorTestReporterAgent* agent, JSC::CallFrame* callFrame, int testId, BunString* name)
{
auto str = name->toWTFString(BunString::ZeroCopy);
agent->reportTestFound(callFrame, testId, str);
}
void Bun__TestReporterAgentReportTestStart(Inspector::InspectorTestReporterAgent* agent, int testId)
{
agent->reportTestStart(testId);
}
enum class BunTestStatus : uint8_t {
Pass,
Fail,
Timeout,
Skip,
Todo,
};
void Bun__TestReporterAgentReportTestEnd(Inspector::InspectorTestReporterAgent* agent, int testId, BunTestStatus bunTestStatus, double elapsed)
{
Protocol::TestReporter::TestStatus status;
switch (bunTestStatus) {
case BunTestStatus::Pass:
status = Protocol::TestReporter::TestStatus::Pass;
break;
case BunTestStatus::Fail:
status = Protocol::TestReporter::TestStatus::Fail;
break;
case BunTestStatus::Timeout:
status = Protocol::TestReporter::TestStatus::Timeout;
break;
case BunTestStatus::Skip:
status = Protocol::TestReporter::TestStatus::Skip;
break;
case BunTestStatus::Todo:
status = Protocol::TestReporter::TestStatus::Todo;
break;
default:
ASSERT_NOT_REACHED();
}
agent->reportTestEnd(testId, status, elapsed);
}
}
InspectorTestReporterAgent::InspectorTestReporterAgent(JSC::JSGlobalObject& globalObject)
: InspectorAgentBase("TestReporter"_s)
, m_globalObject(globalObject)
, m_backendDispatcher(TestReporterBackendDispatcher::create(m_globalObject.inspectorController().backendDispatcher(), this))
, m_frontendDispatcher(makeUnique<TestReporterFrontendDispatcher>(const_cast<FrontendRouter&>(m_globalObject.inspectorController().frontendRouter())))
{
}
InspectorTestReporterAgent::~InspectorTestReporterAgent()
{
if (m_enabled) {
Bun__TestReporterAgentDisable(this);
}
}
void InspectorTestReporterAgent::didCreateFrontendAndBackend(FrontendRouter* frontendRouter, BackendDispatcher* backendDispatcher)
{
this->m_frontendDispatcher = makeUnique<TestReporterFrontendDispatcher>(const_cast<FrontendRouter&>(m_globalObject.inspectorController().frontendRouter()));
}
void InspectorTestReporterAgent::willDestroyFrontendAndBackend(DisconnectReason)
{
disable();
m_frontendDispatcher = nullptr;
}
Protocol::ErrorStringOr<void> InspectorTestReporterAgent::enable()
{
if (m_enabled)
return {};
m_enabled = true;
Bun__TestReporterAgentEnable(this);
return {};
}
Protocol::ErrorStringOr<void> InspectorTestReporterAgent::disable()
{
if (!m_enabled)
return {};
m_enabled = false;
Bun__TestReporterAgentDisable(this);
return {};
}
void InspectorTestReporterAgent::reportTestFound(JSC::CallFrame* callFrame, int testId, const String& name)
{
if (!m_enabled)
return;
JSC::LineColumn lineColumn;
JSC::SourceID sourceID = 0;
String sourceURL;
ZigStackFrame remappedFrame = {};
auto* globalObject = &m_globalObject;
auto& vm = globalObject->vm();
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus {
if (Zig::isImplementationVisibilityPrivate(visitor))
return WTF::IterationStatus::Continue;
if (visitor->hasLineAndColumnInfo()) {
lineColumn = visitor->computeLineAndColumn();
String sourceURLForFrame = visitor->sourceURL();
// Sometimes, the sourceURL is empty.
// For example, pages in Next.js.
if (sourceURLForFrame.isEmpty()) {
// hasLineAndColumnInfo() checks codeBlock(), so this is safe to access here.
const auto& source = visitor->codeBlock()->source();
// source.isNull() is true when the SourceProvider is a null pointer.
if (!source.isNull()) {
auto* provider = source.provider();
// I'm not 100% sure we should show sourceURLDirective here.
if (!provider->sourceURLDirective().isEmpty()) {
sourceURLForFrame = provider->sourceURLDirective();
} else if (!provider->sourceURL().isEmpty()) {
sourceURLForFrame = provider->sourceURL();
} else {
const auto& origin = provider->sourceOrigin();
if (!origin.isNull()) {
sourceURLForFrame = origin.string();
}
}
sourceID = provider->asID();
}
}
sourceURL = sourceURLForFrame;
return WTF::IterationStatus::Done;
}
return WTF::IterationStatus::Continue;
});
if (!sourceURL.isEmpty() and lineColumn.line > 0) {
OrdinalNumber originalLine = OrdinalNumber::fromOneBasedInt(lineColumn.line);
OrdinalNumber originalColumn = OrdinalNumber::fromOneBasedInt(lineColumn.column);
remappedFrame.position.line_zero_based = originalLine.zeroBasedInt();
remappedFrame.position.column_zero_based = originalColumn.zeroBasedInt();
remappedFrame.source_url = Bun::toStringRef(sourceURL);
Bun__remapStackFramePositions(globalObject, &remappedFrame, 1);
sourceURL = remappedFrame.source_url.toWTFString();
lineColumn.line = OrdinalNumber::fromZeroBasedInt(remappedFrame.position.line_zero_based).oneBasedInt();
lineColumn.column = OrdinalNumber::fromZeroBasedInt(remappedFrame.position.column_zero_based).oneBasedInt();
}
m_frontendDispatcher->found(
testId,
sourceID > 0 ? String::number(sourceID) : String(),
sourceURL,
lineColumn.line,
name);
}
void InspectorTestReporterAgent::reportTestStart(int testId)
{
if (!m_enabled || !m_frontendDispatcher)
return;
m_frontendDispatcher->start(testId);
}
void InspectorTestReporterAgent::reportTestEnd(int testId, Protocol::TestReporter::TestStatus status, double elapsed)
{
if (!m_enabled || !m_frontendDispatcher)
return;
m_frontendDispatcher->end(testId, status, elapsed);
}
} // namespace Inspector

View File

@@ -0,0 +1,46 @@
#pragma once
#include "root.h"
#include <JavaScriptCore/AlternateDispatchableAgent.h>
#include <JavaScriptCore/InspectorAgentBase.h>
#include <JavaScriptCore/InspectorBackendDispatchers.h>
#include <JavaScriptCore/InspectorFrontendDispatchers.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <wtf/Forward.h>
#include <wtf/Noncopyable.h>
namespace Inspector {
class FrontendRouter;
class BackendDispatcher;
class TestReporterFrontendDispatcher;
enum class DisconnectReason;
class InspectorTestReporterAgent final : public InspectorAgentBase, public Inspector::TestReporterBackendDispatcherHandler {
WTF_MAKE_NONCOPYABLE(InspectorTestReporterAgent);
public:
InspectorTestReporterAgent(JSC::JSGlobalObject&);
virtual ~InspectorTestReporterAgent();
// InspectorAgentBase
virtual void didCreateFrontendAndBackend(FrontendRouter*, BackendDispatcher*) final;
virtual void willDestroyFrontendAndBackend(DisconnectReason) final;
// TestReporterBackendDispatcherHandler
virtual Protocol::ErrorStringOr<void> enable() final;
virtual Protocol::ErrorStringOr<void> disable() final;
// Public API for reporting test events
void reportTestFound(JSC::CallFrame*, int testId, const String& name);
void reportTestStart(int testId);
void reportTestEnd(int testId, Protocol::TestReporter::TestStatus status, double elapsed);
private:
JSC::JSGlobalObject& m_globalObject;
std::unique_ptr<TestReporterFrontendDispatcher> m_frontendDispatcher;
Ref<TestReporterBackendDispatcher> m_backendDispatcher;
bool m_enabled { false };
};
} // namespace Inspector

View File

@@ -455,8 +455,6 @@ extern "C" void Bun__onFulfillAsyncModule(
}
}
extern "C" bool isBunTest;
JSValue fetchCommonJSModule(
Zig::GlobalObject* globalObject,
JSCommonJSModule* target,

View File

@@ -47,6 +47,8 @@ struct OnLoadResult {
bool wasMock;
};
extern "C" bool isBunTest;
class PendingVirtualModuleResult : public JSC::JSInternalFieldObjectImpl<3> {
public:
using Base = JSC::JSInternalFieldObjectImpl<3>;

View File

@@ -1418,47 +1418,167 @@ pub const VirtualMachine = struct {
pub var has_created_debugger: bool = false;
pub const TestReporterAgent = struct {
handle: ?*Handle = null,
const debug = Output.scoped(.TestReporterAgent, false);
pub const TestStatus = enum(u8) {
pass,
fail,
timeout,
skip,
todo,
};
pub const Handle = opaque {
extern "c" fn Bun__TestReporterAgentReportTestFound(agent: *Handle, callFrame: *JSC.CallFrame, testId: c_int, name: *String) void;
extern "c" fn Bun__TestReporterAgentReportTestStart(agent: *Handle, testId: c_int) void;
extern "c" fn Bun__TestReporterAgentReportTestEnd(agent: *Handle, testId: c_int, bunTestStatus: TestStatus, elapsed: f64) void;
pub fn reportTestFound(this: *Handle, callFrame: *JSC.CallFrame, testId: i32, name: *String) void {
Bun__TestReporterAgentReportTestFound(this, callFrame, testId, name);
}
pub fn reportTestStart(this: *Handle, testId: c_int) void {
Bun__TestReporterAgentReportTestStart(this, testId);
}
pub fn reportTestEnd(this: *Handle, testId: c_int, bunTestStatus: TestStatus, elapsed: f64) void {
Bun__TestReporterAgentReportTestEnd(this, testId, bunTestStatus, elapsed);
}
};
pub export fn Bun__TestReporterAgentEnable(agent: *Handle) void {
if (JSC.VirtualMachine.get().debugger) |*debugger| {
debug("enable", .{});
debugger.test_reporter_agent.handle = agent;
}
}
pub export fn Bun__TestReporterAgentDisable(agent: *Handle) void {
_ = agent; // autofix
if (JSC.VirtualMachine.get().debugger) |*debugger| {
debug("disable", .{});
debugger.test_reporter_agent.handle = null;
}
}
/// Caller must ensure that it is enabled first.
///
/// Since we may have to call .deinit on the name string.
pub fn reportTestFound(this: TestReporterAgent, callFrame: *JSC.CallFrame, test_id: i32, name: *bun.String) void {
debug("reportTestFound", .{});
this.handle.?.reportTestFound(callFrame, test_id, name);
}
/// Caller must ensure that it is enabled first.
pub fn reportTestStart(this: TestReporterAgent, test_id: i32) void {
debug("reportTestStart", .{});
this.handle.?.reportTestStart(test_id);
}
/// Caller must ensure that it is enabled first.
pub fn reportTestEnd(this: TestReporterAgent, test_id: i32, bunTestStatus: TestStatus, elapsed: f64) void {
debug("reportTestEnd", .{});
this.handle.?.reportTestEnd(test_id, bunTestStatus, elapsed);
}
pub fn isEnabled(this: TestReporterAgent) bool {
return this.handle != null;
}
};
pub const LifecycleAgent = struct {
handle: ?*Handle = null,
const debug = Output.scoped(.LifecycleAgent, false);
pub const Handle = opaque {
extern "c" fn Bun__LifecycleAgentReportReload(agent: *Handle) void;
extern "c" fn Bun__LifecycleAgentReportError(agent: *Handle, exception: *JSC.ZigException) void;
extern "c" fn Bun__LifecycleAgentPreventExit(agent: *Handle) void;
extern "c" fn Bun__LifecycleAgentStopPreventingExit(agent: *Handle) void;
pub fn preventExit(this: *Handle) void {
Bun__LifecycleAgentPreventExit(this);
}
pub fn stopPreventingExit(this: *Handle) void {
Bun__LifecycleAgentStopPreventingExit(this);
}
pub fn reportReload(this: *Handle) void {
debug("reportReload", .{});
Bun__LifecycleAgentReportReload(this);
}
pub fn reportError(this: *Handle, exception: *JSC.ZigException) void {
debug("reportError", .{});
Bun__LifecycleAgentReportError(this, exception);
}
};
pub export fn Bun__LifecycleAgentEnable(agent: *Handle) void {
if (JSC.VirtualMachine.get().debugger) |*debugger| {
debug("enable", .{});
debugger.lifecycle_reporter_agent.handle = agent;
}
}
pub export fn Bun__LifecycleAgentDisable(agent: *Handle) void {
_ = agent; // autofix
if (JSC.VirtualMachine.get().debugger) |*debugger| {
debug("disable", .{});
debugger.lifecycle_reporter_agent.handle = null;
}
}
pub fn reportReload(this: *LifecycleAgent) void {
if (this.handle) |handle| {
handle.reportReload();
}
}
pub fn reportError(this: *LifecycleAgent, exception: *JSC.ZigException) void {
if (this.handle) |handle| {
handle.reportError(exception);
}
}
pub fn isEnabled(this: *const LifecycleAgent) bool {
return this.handle != null;
}
};
pub const Debugger = struct {
path_or_port: ?[]const u8 = null,
unix: []const u8 = "",
from_environment_variable: []const u8 = "",
script_execution_context_id: u32 = 0,
next_debugger_id: u64 = 1,
poll_ref: Async.KeepAlive = .{},
wait_for_connection: bool = false,
set_breakpoint_on_first_line: bool = false,
const debug = Output.scoped(.DEBUGGER, false);
test_reporter_agent: TestReporterAgent = .{},
lifecycle_reporter_agent: LifecycleAgent = .{},
must_block_until_connected: bool = false,
pub const log = Output.scoped(.debugger, false);
extern "C" fn Bun__createJSDebugger(*JSC.JSGlobalObject) u32;
extern "C" fn Bun__ensureDebugger(u32, bool) void;
extern "C" fn Bun__startJSDebuggerThread(*JSC.JSGlobalObject, u32, *bun.String) void;
extern "C" fn Bun__startJSDebuggerThread(*JSC.JSGlobalObject, u32, *bun.String, i32) void;
var futex_atomic: std.atomic.Value(u32) = undefined;
pub fn create(this: *VirtualMachine, globalObject: *JSGlobalObject) !void {
debug("create", .{});
JSC.markBinding(@src());
if (has_created_debugger) return;
has_created_debugger = true;
var debugger = &this.debugger.?;
debugger.script_execution_context_id = Bun__createJSDebugger(globalObject);
if (!this.has_started_debugger) {
this.has_started_debugger = true;
futex_atomic = std.atomic.Value(u32).init(0);
var thread = try std.Thread.spawn(.{}, startJSDebuggerThread, .{this});
thread.detach();
pub fn waitForDebuggerIfNecessary(this: *VirtualMachine) void {
const debugger = &(this.debugger orelse return);
if (!debugger.must_block_until_connected) {
return;
}
this.eventLoop().ensureWaker();
defer debugger.must_block_until_connected = false;
if (debugger.wait_for_connection) {
debugger.poll_ref.ref(this);
}
debug("spin", .{});
Debugger.log("spin", .{});
while (futex_atomic.load(.monotonic) > 0) {
std.Thread.Futex.wait(&futex_atomic, 1);
}
if (comptime Environment.allow_assert)
debug("waitForDebugger: {}", .{Output.ElapsedFormatter{
if (comptime Environment.enable_logs)
Debugger.log("waitForDebugger: {}", .{Output.ElapsedFormatter{
.colors = Output.enable_ansi_colors_stderr,
.duration_ns = @truncate(@as(u128, @intCast(std.time.nanoTimestamp() - bun.CLI.start_time))),
}});
@@ -1471,10 +1591,36 @@ pub const VirtualMachine = struct {
}
}
pub fn create(this: *VirtualMachine, globalObject: *JSGlobalObject) !void {
log("create", .{});
JSC.markBinding(@src());
if (!has_created_debugger) {
has_created_debugger = true;
std.mem.doNotOptimizeAway(&TestReporterAgent.Bun__TestReporterAgentDisable);
std.mem.doNotOptimizeAway(&LifecycleAgent.Bun__LifecycleAgentDisable);
std.mem.doNotOptimizeAway(&TestReporterAgent.Bun__TestReporterAgentEnable);
std.mem.doNotOptimizeAway(&LifecycleAgent.Bun__LifecycleAgentEnable);
var debugger = &this.debugger.?;
debugger.script_execution_context_id = Bun__createJSDebugger(globalObject);
if (!this.has_started_debugger) {
this.has_started_debugger = true;
futex_atomic = std.atomic.Value(u32).init(0);
var thread = try std.Thread.spawn(.{}, startJSDebuggerThread, .{this});
thread.detach();
}
this.eventLoop().ensureWaker();
if (debugger.wait_for_connection) {
debugger.poll_ref.ref(this);
debugger.must_block_until_connected = true;
}
}
}
pub fn startJSDebuggerThread(other_vm: *VirtualMachine) void {
var arena = bun.MimallocArena.init() catch unreachable;
Output.Source.configureNamedThread("Debugger");
debug("startJSDebuggerThread", .{});
log("startJSDebuggerThread", .{});
JSC.markBinding(@src());
var vm = JSC.VirtualMachine.init(.{
@@ -1494,9 +1640,10 @@ pub const VirtualMachine = struct {
pub export fn Debugger__didConnect() void {
var this = VirtualMachine.get();
bun.assert(this.debugger.?.wait_for_connection);
this.debugger.?.wait_for_connection = false;
this.debugger.?.poll_ref.unref(this);
if (this.debugger.?.wait_for_connection) {
this.debugger.?.wait_for_connection = false;
this.debugger.?.poll_ref.unref(this);
}
}
fn start(other_vm: *VirtualMachine) void {
@@ -1505,14 +1652,14 @@ pub const VirtualMachine = struct {
var this = VirtualMachine.get();
const debugger = other_vm.debugger.?;
if (debugger.unix.len > 0) {
var url = bun.String.createUTF8(debugger.unix);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url);
if (debugger.from_environment_variable.len > 0) {
var url = bun.String.createUTF8(debugger.from_environment_variable);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url, 1);
}
if (debugger.path_or_port) |path_or_port| {
var url = bun.String.createUTF8(path_or_port);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url, 0);
}
this.global.handleRejectedPromises();
@@ -1523,7 +1670,7 @@ pub const VirtualMachine = struct {
Output.flush();
}
debug("wake", .{});
log("wake", .{});
futex_atomic.store(0, .monotonic);
std.Thread.Futex.wake(&futex_atomic, 1);
@@ -1842,7 +1989,7 @@ pub const VirtualMachine = struct {
if (unix.len > 0) {
this.debugger = Debugger{
.path_or_port = null,
.unix = unix,
.from_environment_variable = unix,
.wait_for_connection = wait_for_connection,
.set_breakpoint_on_first_line = set_breakpoint_on_first_line,
};
@@ -1851,7 +1998,7 @@ pub const VirtualMachine = struct {
.enable => {
this.debugger = Debugger{
.path_or_port = debugger.enable.path_or_port,
.unix = unix,
.from_environment_variable = unix,
.wait_for_connection = wait_for_connection or debugger.enable.wait_for_connection,
.set_breakpoint_on_first_line = set_breakpoint_on_first_line or debugger.enable.set_breakpoint_on_first_line,
};
@@ -2796,11 +2943,23 @@ pub const VirtualMachine = struct {
return null;
}
pub fn ensureDebugger(this: *VirtualMachine, block_until_connected: bool) !void {
if (this.debugger != null) {
try Debugger.create(this, this.global);
if (block_until_connected) {
Debugger.waitForDebuggerIfNecessary(this);
}
}
}
pub fn reloadEntryPoint(this: *VirtualMachine, entry_path: []const u8) !*JSInternalPromise {
this.has_loaded = false;
this.main = entry_path;
this.main_hash = GenericWatcher.getHash(entry_path);
try this.ensureDebugger(true);
try this.entry_point.generate(
this.allocator,
this.bun_watcher != .none,
@@ -2809,10 +2968,6 @@ pub const VirtualMachine = struct {
);
this.eventLoop().ensureWaker();
if (this.debugger != null) {
try Debugger.create(this, this.global);
}
if (!this.bundler.options.disable_transpilation) {
if (try this.loadPreloads()) |promise| {
JSC.JSValue.fromCell(promise).ensureStillAlive();
@@ -2841,9 +2996,7 @@ pub const VirtualMachine = struct {
this.eventLoop().ensureWaker();
if (this.debugger != null) {
try Debugger.create(this, this.global);
}
try this.ensureDebugger(true);
if (!this.bundler.options.disable_transpilation) {
if (try this.loadPreloads()) |promise| {
@@ -3504,10 +3657,15 @@ pub const VirtualMachine = struct {
this.had_errors = true;
defer this.had_errors = prev_had_errors;
if (allow_side_effects and Output.is_github_action) {
defer printGithubAnnotation(exception);
if (allow_side_effects) {
if (this.debugger) |*debugger| {
debugger.lifecycle_reporter_agent.reportError(exception);
}
}
defer if (allow_side_effects and Output.is_github_action)
printGithubAnnotation(exception);
// This is a longer number than necessary because we don't handle this case very well
// At the very least, we shouldn't dump 100 KB of minified code into your terminal.
const max_line_length_with_divot = 512;

View File

@@ -57,6 +57,7 @@ pub const Tag = enum(u3) {
todo,
};
const debug = Output.scoped(.jest, false);
var max_test_id_for_debugger: u32 = 0;
pub const TestRunner = struct {
tests: TestRunner.Test.List = .{},
log: *logger.Log,
@@ -586,7 +587,7 @@ pub const TestScope = struct {
func_arg: []JSValue,
func_has_callback: bool = false,
id: TestRunner.Test.ID = 0,
test_id_for_debugger: TestRunner.Test.ID = 0,
promise: ?*JSInternalPromise = null,
ran: bool = false,
task: ?*TestRunnerTask = null,
@@ -725,6 +726,14 @@ pub const TestScope = struct {
task.test_id,
);
if (task.test_id_for_debugger > 0) {
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
debugger.test_reporter_agent.reportTestStart(@intCast(task.test_id_for_debugger));
}
}
}
if (this.func_has_callback) {
const callback_func = JSC.NewFunctionWithData(
vm.global,
@@ -1177,6 +1186,7 @@ pub const DescribeScope = struct {
.describe = this,
.globalThis = globalObject,
.source_file_path = source.path.text,
.test_id_for_debugger = 0,
};
runner.ref.ref(globalObject.bunVM());
@@ -1185,6 +1195,8 @@ pub const DescribeScope = struct {
}
}
const maybe_report_debugger = max_test_id_for_debugger > 0;
while (i < end) : (i += 1) {
var runner = allocator.create(TestRunnerTask) catch unreachable;
runner.* = .{
@@ -1192,6 +1204,7 @@ pub const DescribeScope = struct {
.describe = this,
.globalThis = globalObject,
.source_file_path = source.path.text,
.test_id_for_debugger = if (maybe_report_debugger) tests[i].test_id_for_debugger else 0,
};
runner.ref.ref(globalObject.bunVM());
@@ -1272,6 +1285,7 @@ pub const WrappedDescribeScope = struct {
pub const TestRunnerTask = struct {
test_id: TestRunner.Test.ID,
test_id_for_debugger: TestRunner.Test.ID,
describe: *DescribeScope,
globalThis: *JSGlobalObject,
source_file_path: string = "",
@@ -1356,7 +1370,6 @@ pub const TestRunnerTask = struct {
jsc_vm.last_reported_error_for_dedupe = .zero;
const test_id = this.test_id;
if (test_id == std.math.maxInt(TestRunner.Test.ID)) {
describe.onTestComplete(globalThis, test_id, true);
Jest.runner.?.runNextTest();
@@ -1366,15 +1379,17 @@ pub const TestRunnerTask = struct {
var test_: TestScope = this.describe.tests.items[test_id];
describe.current_test_id = test_id;
const test_id_for_debugger = test_.test_id_for_debugger;
this.test_id_for_debugger = test_id_for_debugger;
if (test_.func == .zero or !describe.shouldEvaluateScope() or (test_.tag != .only and Jest.runner.?.only)) {
const tag = if (!describe.shouldEvaluateScope()) describe.tag else test_.tag;
switch (tag) {
.todo => {
this.processTestResult(globalThis, .{ .todo = {} }, test_, test_id, describe);
this.processTestResult(globalThis, .{ .todo = {} }, test_, test_id, test_id_for_debugger, describe);
},
.skip => {
this.processTestResult(globalThis, .{ .skip = {} }, test_, test_id, describe);
this.processTestResult(globalThis, .{ .skip = {} }, test_, test_id, test_id_for_debugger, describe);
},
else => {},
}
@@ -1556,17 +1571,18 @@ pub const TestRunnerTask = struct {
}
checkAssertionsCounter(result);
processTestResult(this, this.globalThis, result.*, test_, test_id, describe);
processTestResult(this, this.globalThis, result.*, test_, test_id, this.test_id_for_debugger, describe);
}
fn processTestResult(this: *TestRunnerTask, globalThis: *JSGlobalObject, result: Result, test_: TestScope, test_id: u32, describe: *DescribeScope) void {
fn processTestResult(this: *TestRunnerTask, globalThis: *JSGlobalObject, result: Result, test_: TestScope, test_id: u32, test_id_for_debugger: u32, describe: *DescribeScope) void {
const elapsed = this.started_at.sinceNow();
switch (result.forceTODO(test_.tag == .todo)) {
.pass => |count| Jest.runner.?.reportPass(
test_id,
this.source_file_path,
test_.label,
count,
this.started_at.sinceNow(),
elapsed,
describe,
),
.fail => |count| Jest.runner.?.reportFailure(
@@ -1574,7 +1590,7 @@ pub const TestRunnerTask = struct {
this.source_file_path,
test_.label,
count,
this.started_at.sinceNow(),
elapsed,
describe,
),
.fail_because_expected_has_assertions => {
@@ -1585,7 +1601,7 @@ pub const TestRunnerTask = struct {
this.source_file_path,
test_.label,
0,
this.started_at.sinceNow(),
elapsed,
describe,
);
},
@@ -1600,7 +1616,7 @@ pub const TestRunnerTask = struct {
this.source_file_path,
test_.label,
counter.actual,
this.started_at.sinceNow(),
elapsed,
describe,
);
},
@@ -1613,12 +1629,26 @@ pub const TestRunnerTask = struct {
this.source_file_path,
test_.label,
count,
this.started_at.sinceNow(),
elapsed,
describe,
);
},
.pending => @panic("Unexpected pending test"),
}
if (test_id_for_debugger > 0) {
if (globalThis.bunVM().debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
debugger.test_reporter_agent.reportTestEnd(@intCast(test_id_for_debugger), switch (result) {
.pass => .pass,
.skip => .skip,
.todo => .todo,
else => .fail,
}, @floatFromInt(elapsed));
}
}
}
describe.onTestComplete(globalThis, test_id, result == .skip or (!Jest.runner.?.test_options.run_todo and result == .todo));
Jest.runner.?.runNextTest();
@@ -1803,6 +1833,21 @@ inline fn createScope(
.func_arg = function_args,
.func_has_callback = has_callback,
.timeout_millis = timeout_ms,
.test_id_for_debugger = brk: {
if (!is_skip) {
const vm = globalThis.bunVM();
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
max_test_id_for_debugger += 1;
var name = bun.String.init(label);
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name);
break :brk max_test_id_for_debugger;
}
}
}
break :brk 0;
},
}) catch unreachable;
} else {
var scope = allocator.create(DescribeScope) catch unreachable;

View File

@@ -610,11 +610,13 @@ const Scanner = struct {
return false;
}
if (jest.Jest.runner.?.test_options.coverage.skip_test_files) {
const name_without_extension = slice[0 .. slice.len - ext.len];
inline for (test_name_suffixes) |suffix| {
if (strings.endsWithComptime(name_without_extension, suffix)) {
return false;
if (jest.Jest.runner) |runner| {
if (runner.test_options.coverage.skip_test_files) {
const name_without_extension = slice[0 .. slice.len - ext.len];
inline for (test_name_suffixes) |suffix| {
if (strings.endsWithComptime(name_without_extension, suffix)) {
return false;
}
}
}
}
@@ -846,6 +848,11 @@ 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.
//
try vm.ensureDebugger(false);
const test_files, const search_count = scan: {
if (for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or

View File

@@ -1,4 +1,93 @@
import type { ServerWebSocket, Socket, SocketHandler, WebSocketHandler, Server as WebSocketServer } from "bun";
const enum FramerState {
WaitingForLength,
WaitingForMessage,
}
let socketFramerMessageLengthBuffer: Buffer;
class SocketFramer {
state: FramerState = FramerState.WaitingForLength;
pendingLength: number = 0;
sizeBuffer: Buffer = Buffer.alloc(4);
sizeBufferIndex: number = 0;
bufferedData: Buffer = Buffer.alloc(0);
constructor(private onMessage: (message: string | 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<{ framer: SocketFramer; backend: Backend }>, data: string): void {
if (!!$debug) {
$debug("local:", data);
}
socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0);
socket.write(socketFramerMessageLengthBuffer);
socket.write(data);
}
onData(socket: Socket<{ framer: SocketFramer; backend: Writer }>, 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);
}
if (!!$debug) {
$debug("remote:", messagesToDeliver);
}
if (messagesToDeliver.length === 1) {
this.onMessage(messagesToDeliver[0]);
} else if (messagesToDeliver.length > 1) {
this.onMessage(messagesToDeliver);
}
}
}
interface Backend {
write: (message: string | string[]) => boolean;
close: () => void;
}
export default function (
executionContextId: string,
@@ -7,9 +96,10 @@ export default function (
executionContextId: string,
refEventLoop: boolean,
receive: (...messages: string[]) => void,
) => unknown,
send: (message: string) => void,
) => Backend,
send: (message: string | string[]) => void,
close: () => void,
isAutomatic: boolean,
): void {
let debug: Debugger | undefined;
try {
@@ -18,18 +108,31 @@ export default function (
exit("Failed to start inspector:\n", error);
}
const { protocol, href, host, pathname } = debug.url;
if (!protocol.includes("unix")) {
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
Bun.write(Bun.stderr, `Listening:\n ${dim(href)}\n`);
if (protocol.includes("ws")) {
Bun.write(Bun.stderr, `Inspect in browser:\n ${link(`https://debug.bun.sh/#${host}${pathname}`)}\n`);
// If the user types --inspect, we print the URL to the console.
// If the user is using an editor extension, don't print anything.
if (!isAutomatic) {
if (debug.url) {
const { protocol, href, host, pathname } = debug.url;
if (!protocol.includes("unix")) {
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
Bun.write(Bun.stderr, `Listening:\n ${dim(href)}\n`);
if (protocol.includes("ws")) {
Bun.write(Bun.stderr, `Inspect in browser:\n ${link(`https://debug.bun.sh/#${host}${pathname}`)}\n`);
}
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
}
} else {
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
Bun.write(Bun.stderr, `Listening on ${dim(url)}\n`);
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
}
Bun.write(Bun.stderr, dim("--------------------- Bun Inspector ---------------------") + reset() + "\n");
}
const notifyUrl = process.env["BUN_INSPECT_NOTIFY"] || "";
if (notifyUrl) {
// Only send this once.
process.env["BUN_INSPECT_NOTIFY"] = "";
if (notifyUrl.startsWith("unix://")) {
const path = require("node:path");
notify({
@@ -46,9 +149,17 @@ export default function (
}
}
function unescapeUnixSocketUrl(href) {
if (href.startsWith("unix://%2F")) {
return decodeURIComponent(href.substring("unix://".length));
}
return href;
}
class Debugger {
#url: URL;
#createBackend: (refEventLoop: boolean, receive: (...messages: string[]) => void) => Writer;
#url?: URL;
#createBackend: (refEventLoop: boolean, receive: (...messages: string[]) => void) => Backend;
constructor(
executionContextId: string,
@@ -57,30 +168,65 @@ class Debugger {
executionContextId: string,
refEventLoop: boolean,
receive: (...messages: string[]) => void,
) => unknown,
send: (message: string) => void,
) => Backend,
send: (message: string | string[]) => void,
close: () => void,
) {
this.#url = parseUrl(url);
this.#createBackend = (refEventLoop, receive) => {
const backend = createBackend(executionContextId, refEventLoop, receive);
return {
write: message => {
send.$call(backend, message);
return true;
},
close: () => close.$call(backend),
try {
this.#createBackend = (refEventLoop, receive) => {
const backend = createBackend(executionContextId, refEventLoop, receive);
return {
write: (message: string | string[]) => {
send.$call(backend, message);
return true;
},
close: () => close.$call(backend),
};
};
};
this.#listen();
if (url.startsWith("unix://")) {
this.#connectOverSocket({
unix: unescapeUnixSocketUrl(url),
});
return;
} else if (url.startsWith("fd://")) {
this.#connectOverSocket({
fd: Number(url.substring("fd://".length)),
});
return;
} else if (url.startsWith("fd:")) {
this.#connectOverSocket({
fd: Number(url.substring("fd:".length)),
});
return;
} else if (url.startsWith("unix:")) {
this.#connectOverSocket({
unix: url.substring("unix:".length),
});
return;
} else if (url.startsWith("tcp://")) {
const { hostname, port } = new URL(url);
this.#connectOverSocket({
hostname,
port: port && port !== "0" ? Number(port) : undefined,
});
return;
}
this.#url = parseUrl(url);
this.#listen();
} catch (error) {
console.error(error);
throw error;
}
}
get url(): URL {
get url(): URL | undefined {
return this.#url;
}
#listen(): void {
const { protocol, hostname, port, pathname } = this.#url;
const { protocol, hostname, port, pathname } = this.#url!;
if (protocol === "ws:" || protocol === "wss:" || protocol === "ws+tcp:") {
const server = Bun.serve({
@@ -89,8 +235,8 @@ class Debugger {
fetch: this.#fetch.bind(this),
websocket: this.#websocket,
});
this.#url.hostname = server.hostname;
this.#url.port = `${server.port}`;
this.#url!.hostname = server.hostname;
this.#url!.port = `${server.port}`;
return;
}
@@ -106,6 +252,48 @@ class Debugger {
throw new TypeError(`Unsupported protocol: '${protocol}' (expected 'ws:' or 'ws+unix:')`);
}
#connectOverSocket(networkOptions) {
return Bun.connect<{ framer: SocketFramer; backend: Backend }>({
...networkOptions,
socket: {
open: socket => {
let backend: Backend;
let framer: SocketFramer;
const callback = (...messages: string[]) => {
for (const message of messages) {
framer.send(socket, message);
}
};
framer = new SocketFramer((message: string | string[]) => {
backend.write(message);
});
backend = this.#createBackend(false, callback);
socket.data = {
framer,
backend,
};
socket.ref();
},
data: (socket, bytes) => {
if (!socket.data) {
socket.terminate();
return;
}
socket.data.framer.onData(socket, bytes);
},
drain: socket => {},
close: socket => {
if (socket.data) {
const { backend, framer } = socket.data;
backend.close();
framer.reset();
}
},
},
});
}
get #websocket(): WebSocketHandler<Connection> {
return {
idleTimeout: 0,
@@ -141,7 +329,7 @@ class Debugger {
// TODO?
}
if (!this.#url.protocol.includes("unix") && this.#url.pathname !== pathname) {
if (!this.#url!.protocol.includes("unix") && this.#url!.pathname !== pathname) {
return new Response(null, {
status: 404, // Not Found
});
@@ -161,17 +349,6 @@ class Debugger {
}
}
get #socket(): SocketHandler<Connection> {
return {
open: socket => this.#open(socket, socketWriter(socket)),
data: (socket, message) => this.#message(socket, message.toString()),
drain: socket => this.#drain(socket),
close: socket => this.#close(socket),
error: (socket, error) => this.#error(socket, error),
connectError: (_, error) => exit("Failed to start inspector:\n", error),
};
}
#open(connection: ConnectionOwner, writer: Writer): void {
const { data } = connection;
const { refEventLoop } = data;
@@ -190,6 +367,7 @@ class Debugger {
#message(connection: ConnectionOwner, message: string): void {
const { data } = connection;
const { backend } = data;
$debug("remote:", message);
backend?.write(message);
}
@@ -231,13 +409,6 @@ function webSocketWriter(ws: ServerWebSocket<unknown>): Writer {
};
}
function socketWriter(socket: Socket<unknown>): Writer {
return {
write: message => !!socket.write(message),
close: () => socket.end(),
};
}
function bufferedWriter(writer: Writer): Writer {
let draining = false;
let pendingMessages: string[] = [];
@@ -273,7 +444,7 @@ const defaultHostname = "localhost";
const defaultPort = 6499;
function parseUrl(input: string): URL {
if (input.startsWith("ws://") || input.startsWith("ws+unix://") || input.startsWith("unix://")) {
if (input.startsWith("ws://") || input.startsWith("ws+unix://")) {
return new URL(input);
}
const url = new URL(`ws://${defaultHostname}:${defaultPort}/${randomId()}`);
@@ -356,7 +527,7 @@ type ConnectionOwner = {
type Connection = {
refEventLoop: boolean;
client?: Writer;
backend?: Writer;
backend?: Backend;
};
type Writer = {

View File

@@ -0,0 +1,19 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`junit reporter 1`] = `
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="<dir>/a.test.js" tests="2" failures="1" errors="0" skipped="0" timestamp="2024-12-17T15:37:38.935Z">
<testcase classname="<dir>/a.test.js" name="fail" <failure message="Test failed" type="AssertionError">
Error: expect(received).toBe(expected)
Expected: 2
Received: 1
</failure>
</testcase>
<testcase classname="<dir>/a.test.js" name="success" </testcase>
</testsuite>
</testsuites>"
`;

View File

@@ -1,289 +1,427 @@
import { Subprocess, spawn } from "bun";
import { afterEach, expect, test } from "bun:test";
import { bunEnv, bunExe, randomPort } from "harness";
import { afterEach, expect, test, describe } from "bun:test";
import { bunEnv, bunExe, isPosix, randomPort, tempDirWithFiles } from "harness";
import { WebSocket } from "ws";
import { join } from "node:path";
let inspectee: Subprocess;
import { SocketFramer } from "./socket-framer";
import { JUnitReporter, InspectorSession, connect } from "./junit-reporter";
import stripAnsi from "strip-ansi";
const anyPort = expect.stringMatching(/^\d+$/);
const anyPathname = expect.stringMatching(/^\/[a-z0-9]+$/);
const tests = [
{
args: ["--inspect"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: [`--inspect=${randomPort()}`],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=localhost:0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=localhost/foo/bar"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/bar",
},
},
{
args: ["--inspect=127.0.0.1"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=127.0.0.1/"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=127.0.0.1:0/"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=[::1]"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=[::1]:0"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=[::1]:0/"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=/foo"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo",
},
},
{
args: ["--inspect=/foo/baz/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/baz/",
},
},
{
args: ["--inspect=:0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost:6499/foo/bar"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/bar",
},
},
];
for (const { args, url: expected } of tests) {
test(`bun ${args.join(" ")}`, async () => {
inspectee = spawn({
cwd: import.meta.dir,
cmd: [bunExe(), ...args, "inspectee.js"],
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
describe("websocket", () => {
const tests = [
{
args: ["--inspect"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: [`--inspect=${randomPort()}`],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=localhost:0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=localhost:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=localhost/foo/bar"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/bar",
},
},
{
args: ["--inspect=127.0.0.1"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=127.0.0.1/"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=127.0.0.1:0/"],
url: {
protocol: "ws:",
hostname: "127.0.0.1",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=[::1]"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: "6499",
pathname: anyPathname,
},
},
{
args: ["--inspect=[::1]:0"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=[::1]:0/"],
url: {
protocol: "ws:",
hostname: "[::1]",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/",
},
},
{
args: ["--inspect=/foo"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo",
},
},
{
args: ["--inspect=/foo/baz/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/baz/",
},
},
{
args: ["--inspect=:0"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: anyPathname,
},
},
{
args: ["--inspect=:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost:0/"],
url: {
protocol: "ws:",
hostname: "localhost",
port: anyPort,
pathname: "/",
},
},
{
args: ["--inspect=ws://localhost:6499/foo/bar"],
url: {
protocol: "ws:",
hostname: "localhost",
port: "6499",
pathname: "/foo/bar",
},
},
];
let url: URL | undefined;
let stderr = "";
const decoder = new TextDecoder();
for await (const chunk of inspectee.stderr as ReadableStream) {
stderr += decoder.decode(chunk);
for (const line of stderr.split("\n")) {
try {
url = new URL(line);
} catch {
// Ignore
for (const { args, url: expected } of tests) {
test(`bun ${args.join(" ")}`, async () => {
inspectee = spawn({
cwd: import.meta.dir,
cmd: [bunExe(), ...args, "inspectee.js"],
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
let url: URL | undefined;
let stderr = "";
const decoder = new TextDecoder();
for await (const chunk of inspectee.stderr as ReadableStream) {
stderr += decoder.decode(chunk);
for (const line of stderr.split("\n")) {
try {
url = new URL(line);
} catch {
// Ignore
}
if (url?.protocol.includes("ws")) {
break;
}
}
if (url?.protocol.includes("ws")) {
if (stderr.includes("Listening:")) {
break;
}
}
if (stderr.includes("Listening:")) {
break;
if (!url) {
process.stderr.write(stderr);
throw new Error("Unable to find listening URL");
}
}
if (!url) {
process.stderr.write(stderr);
throw new Error("Unable to find listening URL");
}
const { protocol, hostname, port, pathname } = url;
expect({
protocol,
hostname,
port,
pathname,
}).toMatchObject(expected);
const { protocol, hostname, port, pathname } = url;
expect({
protocol,
hostname,
port,
pathname,
}).toMatchObject(expected);
const webSocket = new WebSocket(url);
expect(
new Promise<void>((resolve, reject) => {
webSocket.addEventListener("open", () => resolve());
webSocket.addEventListener("error", cause => reject(new Error("WebSocket error", { cause })));
webSocket.addEventListener("close", cause => reject(new Error("WebSocket closed", { cause })));
}),
).resolves.toBeUndefined();
const webSocket = new WebSocket(url);
expect(
new Promise<void>((resolve, reject) => {
webSocket.addEventListener("open", () => resolve());
webSocket.addEventListener("error", cause => reject(new Error("WebSocket error", { cause })));
webSocket.addEventListener("close", cause => reject(new Error("WebSocket closed", { cause })));
}),
).resolves.toBeUndefined();
webSocket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1 + 1" } }));
expect(
new Promise(resolve => {
webSocket.addEventListener("message", ({ data }) => {
resolve(JSON.parse(data.toString()));
});
}),
).resolves.toMatchObject({
id: 1,
result: {
webSocket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1 + 1" } }));
expect(
new Promise(resolve => {
webSocket.addEventListener("message", ({ data }) => {
resolve(JSON.parse(data.toString()));
});
}),
).resolves.toMatchObject({
id: 1,
result: {
type: "number",
value: 2,
result: {
type: "number",
value: 2,
},
},
},
});
webSocket.close();
});
}
// FIXME: Depends on https://github.com/oven-sh/bun/pull/4649
test.todo("bun --inspect=ws+unix:///tmp/inspect.sock");
afterEach(() => {
inspectee?.kill();
});
});
describe("unix domain socket without websocket", () => {
if (isPosix) {
async function runTest(path: string, args: string[], env = bunEnv) {
let { promise, resolve, reject } = Promise.withResolvers();
const framer = new SocketFramer(message => {
resolve(JSON.parse(message));
});
let sock;
using listener = Bun.listen({
unix: path,
socket: {
open: socket => {
sock = socket;
framer.send(socket, JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1 + 1" } }));
},
data: (socket, bytes) => {
framer.onData(socket, bytes);
},
error: reject,
},
});
const inspectee = spawn({
cmd: [bunExe(), ...args, join(import.meta.dir, "inspectee.js")],
env,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
const message = await promise;
expect(message).toMatchObject({
id: 1,
result: {
result: { type: "number", value: 2 },
},
});
inspectee.kill();
sock?.end?.();
}
test("bun --inspect=unix://", async () => {
const path = Math.random().toString(36).substring(2, 15) + ".sock";
const url = new URL(`unix://${path}`);
await runTest(path, ["--inspect=" + url.href]);
});
webSocket.close();
});
}
test("bun --inspect=unix:", async () => {
const path = Math.random().toString(36).substring(2, 15) + ".sock";
await runTest(path, ["--inspect=unix:" + path]);
});
// FIXME: Depends on https://github.com/oven-sh/bun/pull/4649
test.todo("bun --inspect=ws+unix:///tmp/inspect.sock");
test("BUN_INSPECT=' unix://' bun --inspect", async () => {
const path = Math.random().toString(36).substring(2, 15) + ".sock";
await runTest(path, [], { ...bunEnv, BUN_INSPECT: "unix://" + path });
});
afterEach(() => {
inspectee?.kill();
test("BUN_INSPECT='unix:' bun --inspect", async () => {
const path = Math.random().toString(36).substring(2, 15) + ".sock";
await runTest(path, [], { ...bunEnv, BUN_INSPECT: "unix:" + path });
});
}
});
test("junit reporter", async () => {
const path = Math.random().toString(36).substring(2, 15) + ".sock";
let reporter: JUnitReporter;
let session: InspectorSession;
const tempdir = tempDirWithFiles("junit-reporter", {
"package.json": `
{
"type": "module",
"scripts": {
"test": "bun a.test.js"
}
}
`,
"a.test.js": `
import { test, expect } from "bun:test";
test("fail", () => {
expect(1).toBe(2);
});
test("success", () => {
expect(1).toBe(1);
});
`,
});
let { resolve, reject, promise } = Promise.withResolvers();
const [socket, subprocess] = await Promise.all([
connect(`unix://${path}`, resolve),
spawn({
cmd: [bunExe(), "--inspect-wait=unix:" + path, "test", join(tempdir, "a.test.js")],
env: bunEnv,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}),
]);
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);
await Promise.all([subprocess.exited, promise]);
for (const [file, suite] of reporter.testSuites.entries()) {
suite.time = 1000 * 5;
suite.timestamp = new Date(2024, 11, 17, 15, 37, 38, 935).toISOString();
}
const report = reporter
.generateReport()
.replaceAll("\r\n", "\n")
.replaceAll("\\", "/")
.replaceAll(tempdir.replaceAll("\\", "/"), "<dir>")
.replaceAll(process.cwd().replaceAll("\\", "/"), "<cwd>")
.trim();
expect(stripAnsi(report)).toMatchSnapshot();
});

View File

@@ -0,0 +1,359 @@
// 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 {
private messageCallbacks: Map<number, (result: any) => void>;
private eventListeners: Map<string, ((params: any) => void)[]>;
private 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);
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();
}
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 (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);
}
});
}

View File

@@ -0,0 +1,79 @@
interface Socket<T = any> {
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);
}
}
}