diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f1eb0a5..fc7392f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: download-url: https://download.swift.org/swift-6.1-release/ubuntu2204/swift-6.1-RELEASE/swift-6.1-RELEASE-ubuntu22.04.tar.gz wasi-backend: Node target: "wasm32-unknown-wasi" + env: | + JAVASCRIPTKIT_DISABLE_TRACING_TRAIT=1 - os: ubuntu-24.04 toolchain: download-url: https://download.swift.org/development/ubuntu2404/swift-DEVELOPMENT-SNAPSHOT-2025-12-01-a/swift-DEVELOPMENT-SNAPSHOT-2025-12-01-a-ubuntu24.04.tar.gz @@ -36,6 +38,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Export matrix env + if: ${{ matrix.entry.env != '' && matrix.entry.env != null }} + run: | + cat <<'EOF' >> "$GITHUB_ENV" + ${{ matrix.entry.env }} + EOF - uses: ./.github/actions/install-swift with: download-url: ${{ matrix.entry.toolchain.download-url }} diff --git a/Makefile b/Makefile index 0c5d0122..135465a7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,9 @@ SWIFT_SDK_ID ?= +ifeq ($(JAVASCRIPTKIT_DISABLE_TRACING_TRAIT),1) + TRACING_ARGS := +else + TRACING_ARGS := --traits Tracing +endif .PHONY: bootstrap bootstrap: @@ -12,6 +17,7 @@ unittest: exit 2; \ } env JAVASCRIPTKIT_EXPERIMENTAL_BRIDGEJS=1 swift package --swift-sdk "$(SWIFT_SDK_ID)" \ + $(TRACING_ARGS) \ --disable-sandbox \ js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift new file mode 100644 index 00000000..456a87ca --- /dev/null +++ b/Package@swift-6.2.swift @@ -0,0 +1,217 @@ +// swift-tools-version:6.2 + +import CompilerPluginSupport +import PackageDescription + +// NOTE: needed for embedded customizations, ideally this will not be necessary at all in the future, or can be replaced with traits +let shouldBuildForEmbedded = Context.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(Bool.init) ?? false +let useLegacyResourceBundling = + Context.environment["JAVASCRIPTKIT_USE_LEGACY_RESOURCE_BUNDLING"].flatMap(Bool.init) ?? false +let enableTracingByEnv = Context.environment["JAVASCRIPTKIT_ENABLE_TRACING"].flatMap(Bool.init) ?? false + +let tracingTrait = Trait( + name: "Tracing", + description: "Enable opt-in Swift <-> JavaScript bridge tracing hooks.", + enabledTraits: [] +) + +let testingLinkerFlags: [LinkerSetting] = [ + .unsafeFlags([ + "-Xlinker", "--stack-first", + "-Xlinker", "--global-base=524288", + "-Xlinker", "-z", + "-Xlinker", "stack-size=524288", + ]) +] + +let package = Package( + name: "JavaScriptKit", + platforms: [ + .macOS(.v13), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6), + .macCatalyst(.v13), + ], + products: [ + .library(name: "JavaScriptKit", targets: ["JavaScriptKit"]), + .library(name: "JavaScriptEventLoop", targets: ["JavaScriptEventLoop"]), + .library(name: "JavaScriptBigIntSupport", targets: ["JavaScriptBigIntSupport"]), + .library(name: "JavaScriptFoundationCompat", targets: ["JavaScriptFoundationCompat"]), + .library(name: "JavaScriptEventLoopTestSupport", targets: ["JavaScriptEventLoopTestSupport"]), + .plugin(name: "PackageToJS", targets: ["PackageToJS"]), + .plugin(name: "BridgeJS", targets: ["BridgeJS"]), + .plugin(name: "BridgeJSCommandPlugin", targets: ["BridgeJSCommandPlugin"]), + ], + traits: [tracingTrait], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax", "600.0.0"..<"603.0.0") + ], + targets: [ + .target( + name: "JavaScriptKit", + dependencies: ["_CJavaScriptKit", "BridgeJSMacros"], + exclude: useLegacyResourceBundling ? [] : ["Runtime"], + resources: useLegacyResourceBundling ? [.copy("Runtime")] : [], + cSettings: shouldBuildForEmbedded + ? [ + .unsafeFlags(["-fdeclspec"]) + ] : nil, + swiftSettings: [ + .enableExperimentalFeature("Extern"), + .define("Tracing", .when(traits: ["Tracing"])), + ] + + (enableTracingByEnv ? [.define("Tracing")] : []) + + (shouldBuildForEmbedded + ? [ + .enableExperimentalFeature("Embedded"), + .unsafeFlags(["-Xfrontend", "-emit-empty-object-file"]), + ] : []) + ), + .target(name: "_CJavaScriptKit"), + .macro( + name: "BridgeJSMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + + .testTarget( + name: "JavaScriptKitTests", + dependencies: ["JavaScriptKit"], + swiftSettings: [ + .enableExperimentalFeature("Extern"), + .define("Tracing", .when(traits: ["Tracing"])), + ] + (enableTracingByEnv ? [.define("Tracing")] : []), + linkerSettings: testingLinkerFlags + ), + + .target( + name: "JavaScriptBigIntSupport", + dependencies: ["_CJavaScriptBigIntSupport", "JavaScriptKit"], + swiftSettings: shouldBuildForEmbedded + ? [ + .enableExperimentalFeature("Embedded"), + .unsafeFlags(["-Xfrontend", "-emit-empty-object-file"]), + ] : [] + ), + .target(name: "_CJavaScriptBigIntSupport", dependencies: ["_CJavaScriptKit"]), + .testTarget( + name: "JavaScriptBigIntSupportTests", + dependencies: ["JavaScriptBigIntSupport", "JavaScriptKit"], + linkerSettings: testingLinkerFlags + ), + + .target( + name: "JavaScriptEventLoop", + dependencies: ["JavaScriptKit", "_CJavaScriptEventLoop"], + swiftSettings: shouldBuildForEmbedded + ? [ + .enableExperimentalFeature("Embedded"), + .unsafeFlags(["-Xfrontend", "-emit-empty-object-file"]), + ] : [] + ), + .target(name: "_CJavaScriptEventLoop"), + .testTarget( + name: "JavaScriptEventLoopTests", + dependencies: [ + "JavaScriptEventLoop", + "JavaScriptKit", + "JavaScriptEventLoopTestSupport", + ], + swiftSettings: [ + .enableExperimentalFeature("Extern") + ], + linkerSettings: testingLinkerFlags + ), + .target( + name: "JavaScriptEventLoopTestSupport", + dependencies: [ + "_CJavaScriptEventLoopTestSupport", + "JavaScriptEventLoop", + ] + ), + .target(name: "_CJavaScriptEventLoopTestSupport"), + .testTarget( + name: "JavaScriptEventLoopTestSupportTests", + dependencies: [ + "JavaScriptKit", + "JavaScriptEventLoopTestSupport", + ], + linkerSettings: testingLinkerFlags + ), + .target( + name: "JavaScriptFoundationCompat", + dependencies: [ + "JavaScriptKit" + ] + ), + .testTarget( + name: "JavaScriptFoundationCompatTests", + dependencies: [ + "JavaScriptFoundationCompat" + ], + linkerSettings: testingLinkerFlags + ), + .plugin( + name: "PackageToJS", + capability: .command( + intent: .custom(verb: "js", description: "Convert a Swift package to a JavaScript package") + ), + path: "Plugins/PackageToJS/Sources" + ), + .plugin( + name: "BridgeJS", + capability: .buildTool(), + dependencies: ["BridgeJSTool"], + path: "Plugins/BridgeJS/Sources/BridgeJSBuildPlugin" + ), + .plugin( + name: "BridgeJSCommandPlugin", + capability: .command( + intent: .custom(verb: "bridge-js", description: "Generate bridging code"), + permissions: [.writeToPackageDirectory(reason: "Generate bridging code")] + ), + dependencies: ["BridgeJSTool"], + path: "Plugins/BridgeJS/Sources/BridgeJSCommandPlugin" + ), + .executableTarget( + name: "BridgeJSTool", + dependencies: [ + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftBasicFormat", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + ], + exclude: ["TS2Swift/JavaScript", "README.md"] + ), + .testTarget( + name: "BridgeJSRuntimeTests", + dependencies: ["JavaScriptKit", "JavaScriptEventLoop"], + exclude: [ + "bridge-js.config.json", + "bridge-js.d.ts", + "bridge-js.global.d.ts", + "Generated/JavaScript", + ], + swiftSettings: [ + .enableExperimentalFeature("Extern") + ], + linkerSettings: testingLinkerFlags + ), + .testTarget( + name: "BridgeJSGlobalTests", + dependencies: ["JavaScriptKit", "JavaScriptEventLoop"], + exclude: [ + "bridge-js.config.json", + "bridge-js.d.ts", + "Generated/JavaScript", + ], + swiftSettings: [ + .enableExperimentalFeature("Extern") + ], + linkerSettings: testingLinkerFlags + ), + ] +) diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/Debugging.md b/Sources/JavaScriptKit/Documentation.docc/Articles/Debugging.md index f766be2e..a8e5d77f 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/Debugging.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/Debugging.md @@ -57,3 +57,25 @@ Alternatively, you can use the official [`C/C++ DevTools Support (DWARF)`](https ![Chrome DevTools](chrome-devtools.png) See [the DevTools team's official introduction](https://developer.chrome.com/blog/wasm-debugging-2020) for more details about the extension. + +## Bridge Call Tracing + +Enable the `Tracing` package trait to compile lightweight hook points for Swift <-> JavaScript calls. Tracing is off by default and adds no runtime overhead unless the trait is enabled: + +```bash +swift build --traits Tracing +``` + +The hooks are invoked at the start and end of each bridge crossing without collecting data for you. For example: + +```swift +let removeCallHook = JSTracing.default.addJSCallHook { info in + let started = Date() + return { print("JS call \(info) finished in \(Date().timeIntervalSince(started))s") } +} + +let removeClosureHook = JSTracing.default.addJSClosureCallHook { info in + print("JSClosure created at \(info.fileID):\(info.line)") + return nil +} +``` diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift index baeb2984..941b3f46 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift @@ -18,7 +18,11 @@ public protocol JSClosureProtocol: JSValueCompatible { public class JSOneshotClosure: JSObject, JSClosureProtocol { private var hostFuncRef: JavaScriptHostFuncRef = 0 - public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) { + public init( + file: String = #fileID, + line: UInt32 = #line, + _ body: @escaping (sending [JSValue]) -> JSValue + ) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -29,12 +33,14 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { } // 3. Retain the given body in static storage by `funcRef`. - JSClosure.sharedClosures.wrappedValue[hostFuncRef] = ( - self, - { + JSClosure.sharedClosures.wrappedValue[hostFuncRef] = .init( + object: self, + body: { defer { self.release() } return body($0) - } + }, + fileID: file, + line: line ) } @@ -114,14 +120,28 @@ public class JSClosure: JSObject, JSClosureProtocol { // `removeValue(forKey:)` on a dictionary with value type containing // `sending`. Wrap the value type with a struct to avoid the crash. struct Entry { - let item: (object: JSObject, body: (sending [JSValue]) -> JSValue) + let object: JSObject + let body: (sending [JSValue]) -> JSValue + #if Tracing + let fileID: String + let line: UInt32 + #endif + + init(object: JSObject, body: @escaping (sending [JSValue]) -> JSValue, fileID: String, line: UInt32) { + self.object = object + self.body = body + #if Tracing + self.fileID = fileID + self.line = line + #endif + } } private var storage: [JavaScriptHostFuncRef: Entry] = [:] init() {} - subscript(_ key: JavaScriptHostFuncRef) -> (object: JSObject, body: (sending [JSValue]) -> JSValue)? { - get { storage[key]?.item } - set { storage[key] = newValue.map { Entry(item: $0) } } + subscript(_ key: JavaScriptHostFuncRef) -> Entry? { + get { storage[key] } + set { storage[key] = newValue } } } @@ -150,7 +170,11 @@ public class JSClosure: JSObject, JSClosureProtocol { }) } - public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) { + public init( + file: String = #fileID, + line: UInt32 = #line, + _ body: @escaping (sending [JSValue]) -> JSValue + ) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -161,7 +185,12 @@ public class JSClosure: JSObject, JSClosureProtocol { } // 3. Retain the given body in static storage by `funcRef`. - Self.sharedClosures.wrappedValue[hostFuncRef] = (self, body) + Self.sharedClosures.wrappedValue[hostFuncRef] = .init( + object: self, + body: body, + fileID: file, + line: line + ) } @available(*, unavailable, message: "JSClosure does not support dictionary literal initialization") @@ -317,14 +346,22 @@ func _call_host_function_impl( _ argc: Int32, _ callbackFuncRef: JavaScriptObjectRef ) -> Bool { - guard let (_, hostFunc) = JSClosure.sharedClosures.wrappedValue[hostFuncRef] else { + guard let entry = JSClosure.sharedClosures.wrappedValue[hostFuncRef] else { return true } + #if Tracing + let traceEnd = JSTracingHooks.beginJSClosureCall( + JSTracing.JSClosureCallInfo(fileID: entry.fileID, line: UInt(entry.line)) + ) + #endif var arguments: [JSValue] = [] for i in 0.. JSObject { - arguments.withRawJSValues { rawValues in + #if Tracing + let jsValues = arguments.map { $0.jsValue } + return new(arguments: jsValues) + #else + return arguments.withRawJSValues { rawValues in rawValues.withUnsafeBufferPointer { bufferPointer in JSObject(id: swjs_call_new(self.id, bufferPointer.baseAddress!, Int32(bufferPointer.count))) } } + #endif } /// A variadic arguments version of `new`. @@ -89,8 +94,22 @@ extension JSObject { invokeNonThrowingJSFunction(arguments: arguments).jsValue } + /// Instantiate an object from this function as a constructor. + /// + /// Guaranteed to return an object because either: + /// + /// - a. the constructor explicitly returns an object, or + /// - b. the constructor returns nothing, which causes JS to return the `this` value, or + /// - c. the constructor returns undefined, null or a non-object, in which case JS also returns `this`. + /// + /// - Parameter arguments: Arguments to be passed to this constructor function. + /// - Returns: A new instance of this constructor. public func new(arguments: [JSValue]) -> JSObject { - arguments.withRawJSValues { rawValues in + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.function(function: self, arguments: arguments)) + defer { traceEnd?() } + #endif + return arguments.withRawJSValues { rawValues in rawValues.withUnsafeBufferPointer { bufferPointer in JSObject(id: swjs_call_new(self.id, bufferPointer.baseAddress!, Int32(bufferPointer.count))) } @@ -103,22 +122,80 @@ extension JSObject { } final func invokeNonThrowingJSFunction(arguments: [JSValue]) -> RawJSValue { - arguments.withRawJSValues { invokeNonThrowingJSFunction(rawValues: $0) } - } - - final func invokeNonThrowingJSFunction(arguments: [JSValue], this: JSObject) -> RawJSValue { - arguments.withRawJSValues { invokeNonThrowingJSFunction(rawValues: $0, this: this) } + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.function(function: self, arguments: arguments)) + #endif + let result = arguments.withRawJSValues { invokeNonThrowingJSFunction(rawValues: $0) } + #if Tracing + traceEnd?() + #endif + return result + } + + #if Tracing + final func invokeNonThrowingJSFunction( + arguments: [JSValue], + this: JSObject, + tracedMethodName: String? = nil + ) -> RawJSValue { + let traceEnd = JSTracingHooks.beginJSCall( + .method(receiver: this, methodName: tracedMethodName, arguments: arguments) + ) + let result = arguments.withRawJSValues { + invokeNonThrowingJSFunction( + rawValues: $0, + this: this + ) + } + traceEnd?() + return result + } + #else + final func invokeNonThrowingJSFunction( + arguments: [JSValue], + this: JSObject + ) -> RawJSValue { + arguments.withRawJSValues { + invokeNonThrowingJSFunction( + rawValues: $0, + this: this + ) + } } + #endif #if !hasFeature(Embedded) final func invokeNonThrowingJSFunction(arguments: [ConvertibleToJSValue]) -> RawJSValue { + #if Tracing + let jsValues = arguments.map { $0.jsValue } + return invokeNonThrowingJSFunction(arguments: jsValues) + #else arguments.withRawJSValues { invokeNonThrowingJSFunction(rawValues: $0) } + #endif } - final func invokeNonThrowingJSFunction(arguments: [ConvertibleToJSValue], this: JSObject) -> RawJSValue { + #if Tracing + final func invokeNonThrowingJSFunction( + arguments: [ConvertibleToJSValue], + this: JSObject, + tracedMethodName: String? = nil + ) -> RawJSValue { + let jsValues = arguments.map { $0.jsValue } + return invokeNonThrowingJSFunction( + arguments: jsValues, + this: this, + tracedMethodName: tracedMethodName + ) + } + #else + final func invokeNonThrowingJSFunction( + arguments: [ConvertibleToJSValue], + this: JSObject + ) -> RawJSValue { arguments.withRawJSValues { invokeNonThrowingJSFunction(rawValues: $0, this: this) } } #endif + #endif final private func invokeNonThrowingJSFunction(rawValues: [RawJSValue]) -> RawJSValue { rawValues.withUnsafeBufferPointer { [id] bufferPointer in diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index 101f13a9..caacd49f 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -94,7 +94,15 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: String) -> ((ConvertibleToJSValue...) -> JSValue)? { guard let function = self[name].function else { return nil } return { (arguments: ConvertibleToJSValue...) in - function(this: self, arguments: arguments) + #if Tracing + return function.invokeNonThrowingJSFunction( + arguments: arguments, + this: self, + tracedMethodName: name + ).jsValue + #else + return function.invokeNonThrowingJSFunction(arguments: arguments, this: self).jsValue + #endif } } @@ -112,7 +120,15 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: JSString) -> ((ConvertibleToJSValue...) -> JSValue)? { guard let function = self[name].function else { return nil } return { (arguments: ConvertibleToJSValue...) in - function(this: self, arguments: arguments) + #if Tracing + return function.invokeNonThrowingJSFunction( + arguments: arguments, + this: self, + tracedMethodName: String(name) + ).jsValue + #else + return function.invokeNonThrowingJSFunction(arguments: arguments, this: self).jsValue + #endif } } diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSThrowingFunction.swift b/Sources/JavaScriptKit/FundamentalObjects/JSThrowingFunction.swift index 7c75ad55..94b5b0ec 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSThrowingFunction.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSThrowingFunction.swift @@ -77,6 +77,15 @@ private func invokeJSFunction( arguments: [ConvertibleToJSValue], this: JSObject? ) throws -> JSValue { + #if Tracing + let jsValues = arguments.map { $0.jsValue } + let traceEnd = JSTracingHooks.beginJSCall( + this.map { + .method(receiver: $0, methodName: nil, arguments: jsValues) + } ?? .function(function: jsFunc, arguments: jsValues) + ) + defer { traceEnd?() } + #endif let id = jsFunc.id let (result, isException) = arguments.withRawJSValues { rawValues in rawValues.withUnsafeBufferPointer { bufferPointer -> (JSValue, Bool) in diff --git a/Sources/JavaScriptKit/JSTracing.swift b/Sources/JavaScriptKit/JSTracing.swift new file mode 100644 index 00000000..8804e9af --- /dev/null +++ b/Sources/JavaScriptKit/JSTracing.swift @@ -0,0 +1,133 @@ +#if Tracing + +/// Hooks for tracing Swift <-> JavaScript bridge calls. +public struct JSTracing: Sendable { + public static let `default` = JSTracing() + + public enum JSCallInfo { + case function(function: JSObject, arguments: [JSValue]) + case method(receiver: JSObject, methodName: String?, arguments: [JSValue]) + } + + /// Register a hook for Swift to JavaScript calls. + /// + /// The hook is invoked at the start of the call. Return a closure to run when + /// the call finishes, or `nil` to skip the end hook. + /// + /// - Returns: A cleanup closure that unregisters the hook. + @discardableResult + public func addJSCallHook( + _ hook: @escaping (_ info: JSCallInfo) -> (() -> Void)? + ) -> () -> Void { + JSTracingHooks.addJSCallHook(hook) + } + + public struct JSClosureCallInfo { + /// The file identifier where the called `JSClosure` was created. + public let fileID: String + /// The line number where the called `JSClosure` was created. + public let line: UInt + } + + /// Register a hook for JavaScript to Swift calls via `JSClosure`. + /// + /// The hook is invoked at the start of the call. Return a closure to run when + /// the call finishes, or `nil` to skip the end hook. + /// + /// - Returns: A cleanup closure that unregisters the hook. + @discardableResult + public func addJSClosureCallHook( + _ hook: @escaping (_ info: JSClosureCallInfo) -> (() -> Void)? + ) -> () -> Void { + JSTracingHooks.addJSClosureCallHook(hook) + } +} + +enum JSTracingHooks { + typealias HookEnd = () -> Void + typealias JSCallHook = (JSTracing.JSCallInfo) -> HookEnd? + typealias JSClosureCallHook = (JSTracing.JSClosureCallInfo) -> HookEnd? + + private final class HookList { + private var hooks: [(id: UInt, hook: Hook)] = [] + private var nextID: UInt = 0 + + var isEmpty: Bool { hooks.isEmpty } + + func add(_ hook: Hook) -> UInt { + let id = nextID + nextID &+= 1 + hooks.append((id, hook)) + return id + } + + func remove(id: UInt) { + hooks.removeAll { $0.id == id } + } + + func forEach(_ body: (Hook) -> Void) { + for entry in hooks { + body(entry.hook) + } + } + } + + private final class Storage { + let jsCallHooks = HookList() + let jsClosureCallHooks = HookList() + } + + private static let storage = LazyThreadLocal(initialize: Storage.init) + + static func addJSCallHook(_ hook: @escaping JSCallHook) -> () -> Void { + let storage = storage.wrappedValue + let id = storage.jsCallHooks.add(hook) + return { storage.jsCallHooks.remove(id: id) } + } + + static func addJSClosureCallHook(_ hook: @escaping JSClosureCallHook) -> () -> Void { + let storage = storage.wrappedValue + let id = storage.jsClosureCallHooks.add(hook) + return { storage.jsClosureCallHooks.remove(id: id) } + } + + static func beginJSCall(_ info: JSTracing.JSCallInfo) -> HookEnd? { + let storage = storage.wrappedValue + guard !storage.jsCallHooks.isEmpty else { return nil } + + var callbacks: [HookEnd] = [] + storage.jsCallHooks.forEach { hook in + if let callback = hook(info) { + callbacks.append(callback) + } + } + + guard !callbacks.isEmpty else { return nil } + return { + for callback in callbacks.reversed() { + callback() + } + } + } + + static func beginJSClosureCall(_ info: JSTracing.JSClosureCallInfo) -> HookEnd? { + let storage = storage.wrappedValue + guard !storage.jsClosureCallHooks.isEmpty else { return nil } + + var callbacks: [HookEnd] = [] + storage.jsClosureCallHooks.forEach { hook in + if let callback = hook(info) { + callbacks.append(callback) + } + } + + guard !callbacks.isEmpty else { return nil } + return { + for callback in callbacks.reversed() { + callback() + } + } + } +} + +#endif diff --git a/Tests/JavaScriptKitTests/JSTracingTests.swift b/Tests/JavaScriptKitTests/JSTracingTests.swift new file mode 100644 index 00000000..84fb9bfc --- /dev/null +++ b/Tests/JavaScriptKitTests/JSTracingTests.swift @@ -0,0 +1,53 @@ +#if Tracing +import JavaScriptKit +import XCTest + +final class JSTracingTests: XCTestCase { + func testJSCallHookReportsMethod() throws { + var startInfo: [JSTracing.JSCallInfo] = [] + var ended = 0 + let remove = JSTracing.default.addJSCallHook { info in + startInfo.append(info) + return { ended += 1 } + } + defer { remove() } + + let globalObject1 = JSObject.global.globalObject1 + let prop5 = try XCTUnwrap(globalObject1.prop_5.object) + _ = prop5.func6!(true, 1, 2) + + XCTAssertEqual(startInfo.count, 1) + guard case let .method(receiver, methodName, arguments) = startInfo.first else { + XCTFail("Expected method info") + return + } + XCTAssertEqual(receiver.id, prop5.id) + XCTAssertEqual(methodName, "func6") + XCTAssertEqual(arguments, [.boolean(true), .number(1), .number(2)]) + XCTAssertEqual(ended, 1) + } + + func testJSClosureCallHookReportsMetadata() throws { + var startInfo: [JSTracing.JSClosureCallInfo] = [] + var ended = 0 + let remove = JSTracing.default.addJSClosureCallHook { info in + startInfo.append(info) + return { ended += 1 } + } + defer { remove() } + + let globalObject1 = JSObject.global.globalObject1 + let prop6 = try XCTUnwrap(globalObject1.prop_6.object) + let closure = JSClosure(file: "TracingTests.swift", line: 4242) { _ in .number(7) } + prop6.host_func_1 = .object(closure) + + let callHost = try XCTUnwrap(prop6.call_host_1.function) + XCTAssertEqual(callHost(), .number(7)) + + XCTAssertEqual(startInfo.count, 1) + XCTAssertEqual(startInfo.first?.fileID, "TracingTests.swift") + XCTAssertEqual(startInfo.first?.line, 4242) + XCTAssertEqual(ended, 1) + } +} +#endif