import ExpoModulesJSI import Foundation import Testing @Suite @JavaScriptActor struct JavaScriptRuntimeTests { let runtime = JavaScriptRuntime() @Test func `get global`() { // Just check it does not crash _ = runtime.global() } @Test func `create plain object`() { _ = runtime.createObject() } @Test func `create object with prototype`() { let prototype = runtime.createObject() prototype.setProperty("test", value: 123) let object = runtime.createObject(prototype: prototype) #expect(object.getProperty("test").getDouble() == 123) } @Test func `create function`() { let fn = runtime.createFunction("function name") { this, arguments in return .undefined } #expect(fn.asValue().isFunction() == true) } @Test func `evaluate script`() throws { #expect(try runtime.eval("'hello' + ' ' + 'world'").getString() == "hello world") #expect(try runtime.eval("(function() {})").isFunction() == true) } @Test func `evaluate multiline script`() throws { #expect(try runtime.eval("const expo = 'expo'", "expo + 'jsi'").getString() == "expojsi") } @Test func `evaluate throws on error`() { #expect(throws: ScriptEvaluationError.self) { _ = try runtime.eval("1 + *") } } @Test func `evaluate to promise`() async throws { let result = try await runtime.evalAsync("'hello'") #expect(result.getString() == "hello") } @Test func `is equatable`() { #expect((runtime == runtime) == true) #expect((runtime != JavaScriptRuntime()) == true) } @Test func `execute sync evaluates JavaScript from non-async context`() throws { let result = try runtime.execute { try self.runtime.eval("2 + 2").getInt() } #expect(result == 4) } @Test func `execute sync throws error from non-async context`() { #expect(throws: ScriptEvaluationError.self) { _ = try runtime.execute { try self.runtime.eval("1 + *") } } } @Test func `execute async returns value`() async throws { let result: String = try await runtime.execute { await Task.yield() return "hello" } #expect(result == "hello") } @Test func `execute async awaits JavaScript evaluation`() async throws { let result = try await runtime.execute { try await self.runtime.evalAsync("Promise.resolve('async result')").getString() } #expect(result == "async result") } @Test func `execute async throws error`() async { await #expect(throws: ScriptEvaluationError.self) { _ = try await runtime.execute { await Task.yield() try self.runtime.eval("invalid syntax +++") } } } @Test func `execute async with operations`() async throws { let result = try await runtime.execute { await Task.yield() let obj = self.runtime.createObject() obj.setProperty("value", value: 100) return obj.getProperty("value").getInt() } #expect(result == 100) } // The execute overloads have a same-thread fast path and a cross-thread path that // schedules the closure onto the JS thread and pumps the caller's run loop until it // completes. The tests above run on `@JavaScriptActor` (the JS thread), so they only // exercise the fast path. The next three hop off the JS thread first to cover the // cross-thread scheduling + run-loop pump. The sync overloads of `execute` are // `@available(*, noasync)`, so the caller must be a real synchronous thread — wrapping // in `Task.detached` would stay on the cooperative pool and trip the noasync diagnostic. @Test func `execute sync from off-thread caller`() async throws { let runtime = self.runtime let result = try await onSyncOffThread { try runtime.execute { @JavaScriptActor in return runtime.global().hasProperty("Object") ? 1 : 0 } } #expect(result == 1) } @Test func `execute blocking-async from off-thread caller`() async throws { let runtime = self.runtime let result = try await onSyncOffThread { try runtime.execute { @JavaScriptActor () async in await Task.yield() return runtime.global().hasProperty("Object") ? 1 : 0 } } #expect(result == 1) } @Test func `execute sync rethrows from off-thread caller`() async throws { let runtime = self.runtime await #expect(throws: ScriptEvaluationError.self) { try await onSyncOffThread { try runtime.execute { @JavaScriptActor in try runtime.eval("invalid syntax +++") } } } } // MARK: - Host objects @Test func `host object get property`() { let hostObject = runtime.createHostObject( get: { name in if name == "foo" { return JavaScriptValue(self.runtime, 42) } return .undefined }, getPropertyNames: { ["foo"] } ) #expect(hostObject.getProperty("foo").getInt() == 42) #expect(hostObject.getProperty("unknown").isUndefined()) } @Test func `host object set property`() { var storedValue: Int? let hostObject = runtime.createHostObject( get: { name in if name == "value", let storedValue { return JavaScriptValue(self.runtime, storedValue) } return .undefined }, set: { name, value in if name == "value" { storedValue = value.getInt() } }, getPropertyNames: { ["value"] } ) hostObject.setProperty("value", value: 99) #expect(storedValue == 99) #expect(hostObject.getProperty("value").getInt() == 99) } @Test func `host object property names`() throws { let hostObject = runtime.createHostObject( get: { name in switch name { case "a": return JavaScriptValue(self.runtime, 1) case "b": return JavaScriptValue(self.runtime, 2) default: return .undefined } }, getPropertyNames: { ["a", "b"] } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let keys = try runtime.eval("Object.keys(globalThis.hostObj)").getArray() #expect(keys.length == 2) #expect(keys[0].getString() == "a") #expect(keys[1].getString() == "b") } @Test func `host object accessible from JavaScript`() throws { let hostObject = runtime.createHostObject( get: { name in if name == "greeting" { return JavaScriptValue(self.runtime, "hello") } return .undefined }, getPropertyNames: { ["greeting"] } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval("globalThis.hostObj.greeting") #expect(result.getString() == "hello") } @Test func `isHostObject distinguishes host objects from plain ones`() { let hostObject = runtime.createHostObject( get: { _ in .undefined } ) let plainObject = runtime.createObject() #expect(hostObject.isHostObject() == true) #expect(plainObject.isHostObject() == false) } @Test func `host object default getPropertyNames returns no keys in JavaScript`() throws { let hostObject = runtime.createHostObject( get: { _ in .undefined } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let count = try runtime.eval("Object.keys(globalThis.hostObj).length") #expect(count.getInt() == 0) } // MARK: - Host object error propagation @Test func `throwing host object setter propagates error to JavaScript`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "set failed" } } let hostObject = runtime.createHostObject( get: { _ in .undefined }, set: { _, _ in throw TestError() } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo = 1; 'no error' } catch (e) { e.message } """) #expect(result.getString().contains("set failed")) } @Test func `throwing host object setter with JavaScriptThrowable preserves code`() throws { struct TypedError: JavaScriptThrowable { var message: String { "read only" } var code: String { "ERR_READ_ONLY" } } let hostObject = runtime.createHostObject( get: { _ in .undefined }, set: { _, _ in throw TypedError() } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo = 1; null } catch (e) { [e.message, e.code] } """ ).getArray() #expect(result[0].getString() == "read only") #expect(result[1].getString() == "ERR_READ_ONLY") } @Test func `throwing host object getter propagates error to JavaScript`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "get failed" } } let hostObject = runtime.createHostObject( get: { _ in throw TestError() } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo; 'no error' } catch (e) { e.message } """) #expect(result.getString().contains("get failed")) } @Test func `throwing host object getter with JavaScriptThrowable preserves code`() throws { struct TypedError: JavaScriptThrowable { var message: String { "missing" } var code: String { "ERR_MISSING" } } let hostObject = runtime.createHostObject( get: { _ in throw TypedError() } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo; null } catch (e) { [e.message, e.code] } """ ).getArray() #expect(result[0].getString() == "missing") #expect(result[1].getString() == "ERR_MISSING") } @Test func `host object setter recovers after throwing`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "boom" } } var stored: Int = 0 var shouldThrow = true let hostObject = runtime.createHostObject( get: { _ in JavaScriptValue(self.runtime, stored) }, set: { _, value in if shouldThrow { throw TestError() } stored = value.getInt() }, getPropertyNames: { ["value"] } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) // First write throws and is caught in JS. let firstAttempt = try runtime.eval( """ try { globalThis.hostObj.value = 1; 'no error' } catch (e) { e.message } """) #expect(firstAttempt.getString().contains("boom")) // Subsequent write must succeed — verifies the C++ thread-local error // state is cleared after being rethrown, not leaked to the next call. shouldThrow = false let secondAttempt = try runtime.eval( """ try { globalThis.hostObj.value = 7; globalThis.hostObj.value } catch (e) { -1 } """) #expect(secondAttempt.getInt() == 7) } @Test func `host object setter error does not pollute later getter`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "set failed" } } let hostObject = runtime.createHostObject( get: { name in if name == "ok" { return JavaScriptValue(self.runtime, 123) } return .undefined }, set: { _, _ in throw TestError() }, getPropertyNames: { ["ok"] } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) // A failing set followed by a successful get must not surface the // earlier set error — checks the thread-local error slot is cleared. let result = try runtime.eval( """ try { globalThis.hostObj.value = 1 } catch (e) {} globalThis.hostObj.ok """) #expect(result.getInt() == 123) } @Test func `read-only host object rejects assignment from JavaScript`() throws { let hostObject = runtime.createHostObject( get: { _ in JavaScriptValue(self.runtime, 1) } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) // No `set` was provided — the C++ side raises a `jsi::JSError` directly, // without crossing the Swift boundary. let result = try runtime.eval( """ try { globalThis.hostObj.foo = 1; 'no error' } catch (e) { e.message } """) let message = result.getString() #expect(message.contains("read-only host object")) #expect(message.contains("'foo'")) } @Test func `non-throwing host object setter does not trigger error`() throws { var stored: Int = 0 let hostObject = runtime.createHostObject( get: { _ in JavaScriptValue(self.runtime, stored) }, set: { _, value in stored = value.getInt() }, getPropertyNames: { ["value"] } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.value = 7; globalThis.hostObj.value } catch (e) { -1 } """) #expect(result.getInt() == 7) } @Test func `host getter that calls failing JS preserves the original error`() throws { try runtime.eval( """ globalThis.throwTagged = function () { const e = new Error('inner failure'); e.code = 'ERR_INNER'; throw e; }; """) let throwTagged = try runtime.global().getPropertyAsFunction("throwTagged") let hostObject = runtime.createHostObject( get: { _ in // Calling JS that throws surfaces an `expo.CppError` wrapping the original // `jsi::JSError`. Letting it propagate exercises the CppError relay path. _ = try throwTagged.call() return .undefined } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo; null } catch (e) { [e.message, e.code] } """ ).getArray() #expect(result[0].getString() == "inner failure") #expect(result[1].getString() == "ERR_INNER") } @Test func `host setter that calls failing JS preserves the original error`() throws { try runtime.eval( """ globalThis.throwTagged = function () { const e = new Error('inner setter failure'); e.code = 'ERR_SETTER'; throw e; }; """) let throwTagged = try runtime.global().getPropertyAsFunction("throwTagged") let hostObject = runtime.createHostObject( get: { _ in .undefined }, set: { _, _ in _ = try throwTagged.call() } ) runtime.global().setProperty("hostObj", value: hostObject.asValue()) let result = try runtime.eval( """ try { globalThis.hostObj.foo = 1; null } catch (e) { [e.message, e.code] } """ ).getArray() #expect(result[0].getString() == "inner setter failure") #expect(result[1].getString() == "ERR_SETTER") } // MARK: - Host function error propagation @Test func `throwing host function propagates error to JavaScript`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "something went wrong" } } let fn = runtime.createFunction("failing") { this, arguments in throw TestError() } runtime.global().setProperty("failing", value: fn.asValue()) let result = try runtime.eval( """ try { failing(); 'no error' } catch (e) { e.message } """) #expect(result.getString().contains("something went wrong")) } @Test func `throwing host function is catchable in JavaScript try-catch`() throws { struct TestError: Error, CustomStringConvertible { var description: String { "custom error message" } } let fn = runtime.createFunction("throwIt") { this, arguments in throw TestError() } runtime.global().setProperty("throwIt", value: fn.asValue()) let result = try runtime.eval( """ var caught = false; var message = ''; try { throwIt(); } catch (e) { caught = true; message = e.message; } [caught, message] """ ).getArray() #expect(result[0].getBool() == true) #expect(result[1].getString().contains("custom error message")) } @Test func `non-throwing host function does not trigger error`() throws { let fn = runtime.createFunction("ok") { this, arguments in return JavaScriptValue(self.runtime, 42) } runtime.global().setProperty("ok", value: fn.asValue()) let result = try runtime.eval("try { ok() } catch (e) { -1 }") #expect(result.getInt() == 42) } // MARK: - Async functions @Test func `async function returns promise`() throws { let fn = runtime.createAsyncFunction("asyncFn") { this, arguments in return JavaScriptValue(self.runtime, 42) } let result = try fn.call() #expect(result.isObject()) #expect(result.is("Promise")) } @Test func `async function propagates promise construction failure`() throws { let fn = runtime.createAsyncFunction("asyncFn") { this, arguments in return JavaScriptValue(self.runtime, 42) } runtime.global().setProperty("asyncFn", value: fn.asValue()) try runtime.eval("globalThis.Promise = undefined") #expect(throws: Error.self) { _ = try runtime.eval("globalThis.asyncFn()") } } @Test func `async function resolves with value`() async throws { let fn = runtime.createAsyncFunction("asyncFn") { this, arguments in return JavaScriptValue(self.runtime, 42) } let result = try await fn.call().getPromise().await() #expect(result.getInt() == 42) } @Test func `async function receives arguments`() async throws { let fn = runtime.createAsyncFunction("add") { this, arguments in let a = arguments[0].getInt() let b = arguments[1].getInt() return JavaScriptValue(self.runtime, a + b) } let result = try await fn.call(arguments: 20, 22).getPromise().await() #expect(result.getInt() == 42) } @Test func `async function rejects on error`() async throws { struct TestError: Error { var localizedDescription: String { "something went wrong" } } let fn = runtime.createAsyncFunction("failing") { this, arguments in throw TestError() } await #expect(throws: Error.self) { try await fn.call().getPromise().await() } } @Test func `async function callable from JavaScript`() async throws { let fn = runtime.createAsyncFunction("greet") { this, arguments in let name = arguments[0].getString() return JavaScriptValue(self.runtime, "Hello, \(name)!") } runtime.global().setProperty("greet", value: fn.asValue()) let result = try await runtime.evalAsync("Promise.resolve(greet('World'))") #expect(result.getString() == "Hello, World!") } // MARK: - Class creation @Test func `create class and construct instance`() throws { let klass = try runtime.createClass(name: "Point") { this, arguments in let obj = this.getObject() obj.setProperty("x", value: arguments[0]) obj.setProperty("y", value: arguments[1]) return this } let instance = try klass.callAsConstructor(10, 20) #expect(instance.getObject().getProperty("x").getInt() == 10) #expect(instance.getObject().getProperty("y").getInt() == 20) } @Test func `create class with inheritance`() throws { let baseClass = try runtime.createClass(name: "Base") { this, arguments in this.getObject().setProperty("base", value: true) return this } let childClass = try runtime.createClass(name: "Child", inheriting: baseClass) { this, arguments in this.getObject().setProperty("child", value: true) return this } let instance = try childClass.callAsConstructor() #expect(instance.getObject().getProperty("child").getBool() == true) } @Test func `create class is callable from JavaScript`() throws { let klass = try runtime.createClass(name: "Greeter") { this, arguments in this.getObject().setProperty("name", value: arguments[0]) return this } runtime.global().setProperty("Greeter", value: klass.asValue()) let result = try runtime.eval("new Greeter('World').name") #expect(result.getString() == "World") } @Test func `created class has correct name`() throws { let klass = try runtime.createClass(name: "MyClass") { this, arguments in return this } #expect(klass.asObject().getProperty("name").getString() == "MyClass") } @Test func `create class throws on invalid name with special characters`() { #expect(throws: JavaScriptRuntime.InvalidIdentifierError.self) { _ = try runtime.createClass(name: "bad); evil(") { this, _ in this } } } @Test func `create class throws on empty name`() { #expect(throws: JavaScriptRuntime.InvalidIdentifierError.self) { _ = try runtime.createClass(name: "") { this, _ in this } } } @Test func `create class throws on name starting with digit`() { #expect(throws: JavaScriptRuntime.InvalidIdentifierError.self) { _ = try runtime.createClass(name: "1Foo") { this, _ in this } } } @Test func `create class accepts valid identifier with dollar and underscore`() throws { let klass = try runtime.createClass(name: "$_Foo123") { this, _ in this } #expect(klass.asObject().getProperty("name").getString() == "$_Foo123") } // MARK: - Unsafe pointee access @Test func `withUnsafePointee returns non-null pointee`() { runtime.withUnsafePointee { runtimePointee in #expect(runtimePointee != UnsafeMutableRawPointer(bitPattern: 0)) } } @Test func `withUnsafePointee returns value from closure`() { let result = runtime.withUnsafePointee { _ in return 42 } #expect(result == 42) } @Test func `withUnsafePointee returns same pointee across calls`() { let pointee1 = runtime.withUnsafePointee { return $0 } let pointee2 = runtime.withUnsafePointee { return $0 } #expect(pointee1 == pointee2) } @Test func `init from unsafePointer creates functional runtime`() throws { let newRuntime = runtime.withUnsafePointee { runtimePointee in return JavaScriptRuntime(unsafePointer: runtimePointee) } let result = try newRuntime.eval("1 + 2") #expect(result.getInt() == 3) } @Test func `id is equal across wrappers of the same underlying runtime`() { // A second wrapper around the same underlying `jsi::Runtime` shares the runtime's identity, even // though it is a distinct `JavaScriptRuntime` instance (so `===` differs). This is the guarantee // `id` provides over wrapper identity. let otherWrapper = runtime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) } #expect(otherWrapper.id == runtime.id) #expect(otherWrapper !== runtime) } @Test func `id differs between distinct runtimes`() { let otherRuntime = JavaScriptRuntime() #expect(otherRuntime.id != runtime.id) } @Test func `creating and releasing standalone runtimes repeatedly does not crash`() throws { // Each standalone runtime owns its Hermes runtime and destroys it on `deinit`. Cycling through // many create/use/release rounds exercises that teardown and would surface a use-after-free or // double-free (destroying a runtime must not corrupt a subsequently created one). Calling `is` // caches a `PropNameID` on the runtime, so this also covers releasing cached JSI objects before // the runtime is freed. for index in 0..<20 { let localRuntime = JavaScriptRuntime() let value = try localRuntime.eval("({ index: \(index) })") #expect(value.is("Object") == true) #expect(value.getObject().getProperty("index").getInt() == index) } } // MARK: - Long-lived objects teardown /// Records whether `allowRelease()` was called. final class TrackedObject: LongLivedObject { private(set) var released = false func allowRelease() { released = true } } @Test func `tearing down the runtime clears its long-lived objects`() { let tracked = TrackedObject() do { let localRuntime = JavaScriptRuntime() localRuntime.longLivedObjects.add(tracked) #expect(localRuntime.longLivedObjects.count == 1) // Leaving the scope releases the runtime. Its `deinit` destroys the owned Hermes runtime, // tearing down the JS heap, which drops the teardown object's native state and fires its // deallocator, sweeping the collection. } #expect(tracked.released == true) } @Test func `an object removed before teardown is not released by the sweep`() { let tracked = TrackedObject() do { let localRuntime = JavaScriptRuntime() localRuntime.longLivedObjects.add(tracked) localRuntime.longLivedObjects.remove(tracked) } #expect(tracked.released == false) } @Test func `wrapping the same runtime again does not sweep the first wrapper's objects`() { // Each wrapper pins its own teardown object under a per-wrapper property name. A second wrapper // of the same underlying runtime must not overwrite the first's pinned object (which would let // it be collected early and sweep the first wrapper's collection while the runtime is alive). let tracked = TrackedObject() runtime.longLivedObjects.add(tracked) _ = runtime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) } #expect(tracked.released == false) #expect(runtime.longLivedObjects.count == 1) } } /// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the /// async test. The thread has a real run loop, which the cross-thread `execute` path pumps. private func onSyncOffThread( _ body: @escaping @Sendable () throws -> R ) async throws -> R { return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Thread.detachNewThread { do { continuation.resume(returning: try body()) } catch { continuation.resume(throwing: error) } } } }