diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index caacd49f2..1b6facada 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -153,10 +153,18 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: String) -> JSValue { get { assertOnOwnerThread(hint: "reading '\(name)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: name)) + defer { traceEnd?() } + #endif return getJSValue(this: self, name: JSString(name)) } set { assertOnOwnerThread(hint: "writing '\(name)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: name, value: newValue)) + defer { traceEnd?() } + #endif setJSValue(this: self, name: JSString(name), value: newValue) } } @@ -167,10 +175,20 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: JSString) -> JSValue { get { assertOnOwnerThread(hint: "reading '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: String(name))) + defer { traceEnd?() } + #endif return getJSValue(this: self, name: name) } set { assertOnOwnerThread(hint: "writing '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: String(name), value: newValue) + ) + defer { traceEnd?() } + #endif setJSValue(this: self, name: name, value: newValue) } } @@ -181,10 +199,20 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ index: Int) -> JSValue { get { assertOnOwnerThread(hint: "reading '\(index)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: String(index))) + defer { traceEnd?() } + #endif return getJSValue(this: self, index: Int32(index)) } set { assertOnOwnerThread(hint: "writing '\(index)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: String(index), value: newValue) + ) + defer { traceEnd?() } + #endif setJSValue(this: self, index: Int32(index), value: newValue) } } @@ -195,10 +223,20 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: JSSymbol) -> JSValue { get { assertOnOwnerThread(hint: "reading '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: "<>")) + defer { traceEnd?() } + #endif return getJSValue(this: self, symbol: name) } set { assertOnOwnerThread(hint: "writing '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: "<>", value: newValue) + ) + defer { traceEnd?() } + #endif setJSValue(this: self, symbol: name, value: newValue) } } diff --git a/Sources/JavaScriptKit/JSTracing.swift b/Sources/JavaScriptKit/JSTracing.swift index 8804e9afb..28e2a1bf8 100644 --- a/Sources/JavaScriptKit/JSTracing.swift +++ b/Sources/JavaScriptKit/JSTracing.swift @@ -7,6 +7,8 @@ public struct JSTracing: Sendable { public enum JSCallInfo { case function(function: JSObject, arguments: [JSValue]) case method(receiver: JSObject, methodName: String?, arguments: [JSValue]) + case propertyGet(receiver: JSObject, propertyName: String) + case propertySet(receiver: JSObject, propertyName: String, value: JSValue) } /// Register a hook for Swift to JavaScript calls. diff --git a/Tests/JavaScriptKitTests/JSTracingTests.swift b/Tests/JavaScriptKitTests/JSTracingTests.swift index 84fb9bfc6..755e89d49 100644 --- a/Tests/JavaScriptKitTests/JSTracingTests.swift +++ b/Tests/JavaScriptKitTests/JSTracingTests.swift @@ -16,15 +16,69 @@ final class JSTracingTests: XCTestCase { 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 { + let methodEvents = startInfo.filter { + if case .method = $0 { return true } + return false + } + XCTAssertEqual(methodEvents.count, 1) + guard case let .method(receiver, methodName, arguments) = methodEvents.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) + XCTAssertEqual(ended, startInfo.count) + } + + func testJSCallHookReportsPropertyAccess() throws { + var startInfo: [JSTracing.JSCallInfo] = [] + var ended = 0 + let remove = JSTracing.default.addJSCallHook { info in + startInfo.append(info) + return { ended += 1 } + } + defer { remove() } + + let obj = JSObject() + obj.foo = .number(42) + + // Reset after setup so we only capture the reads/writes below. + startInfo.removeAll() + ended = 0 + + // Read a property (triggers propertyGet) + let _: JSValue = obj.foo + + // Write a property (triggers propertySet) + obj.foo = .number(999) + + let propEvents = startInfo.filter { + switch $0 { + case .propertyGet(_, let name) where name == "foo": return true + case .propertySet(_, let name, _) where name == "foo": return true + default: return false + } + } + + XCTAssertEqual(propEvents.count, 2) + + guard case .propertyGet(let getReceiver, let getName) = propEvents[0] else { + XCTFail("Expected propertyGet info") + return + } + XCTAssertEqual(getReceiver.id, obj.id) + XCTAssertEqual(getName, "foo") + + guard case .propertySet(let setReceiver, let setName, let setValue) = propEvents[1] else { + XCTFail("Expected propertySet info") + return + } + XCTAssertEqual(setReceiver.id, obj.id) + XCTAssertEqual(setName, "foo") + XCTAssertEqual(setValue, .number(999)) + + XCTAssertEqual(ended, startInfo.count) } func testJSClosureCallHookReportsMetadata() throws {