diff --git a/Sources/SkipScript/JSContext.swift b/Sources/SkipScript/JSContext.swift
index ced7f83..b97ce25 100644
--- a/Sources/SkipScript/JSContext.swift
+++ b/Sources/SkipScript/JSContext.swift
@@ -139,6 +139,50 @@ public class JSContext {
JavaScriptCore.JSGarbageCollect(context)
}
+ /// Creates a JavaScript Promise and returns the promise along with its resolve and reject functions.
+ ///
+ /// This allows Swift code to control when a Promise resolves or rejects by calling the
+ /// returned resolve and reject functions directly.
+ public func createPromise() -> JSPromise? {
+ guard let result = evaluateScript("""
+ (function() {
+ var resolve, reject;
+ var promise = new Promise(function(res, rej) { resolve = res; reject = rej; });
+ return {promise: promise, resolve: resolve, reject: reject};
+ })()
+ """) else {
+ return nil
+ }
+ let promise = result.objectForKeyedSubscript("promise")
+ let resolve = result.objectForKeyedSubscript("resolve")
+ let reject = result.objectForKeyedSubscript("reject")
+ return (promise: promise, resolveFunction: resolve, rejectFunction: reject)
+ }
+
+ /// Awaits the resolution of a JavaScript Promise, returning its resolved value.
+ ///
+ /// If the Promise rejects, a ``JSError`` is thrown with the rejection reason.
+ @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
+ public func awaitPromise(_ promise: JSValue) async throws -> JSValue {
+ try await withCheckedThrowingContinuation { continuation in
+ let onResolve = JSValue(newFunctionIn: self) { ctx, obj, args in
+ continuation.resume(returning: args.first ?? JSValue(undefinedIn: ctx))
+ return JSValue(undefinedIn: ctx)
+ }
+ let onReject = JSValue(newFunctionIn: self) { ctx, obj, args in
+ let message = args.first?.toString() ?? "Promise rejected"
+ continuation.resume(throwing: JSError(message: message))
+ return JSValue(undefinedIn: ctx)
+ }
+ let thenFn = promise.objectForKeyedSubscript("then")
+ do {
+ try thenFn.call(withArguments: [onResolve, onReject], this: promise)
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+
}
@@ -278,6 +322,35 @@ public class JSValue {
JavaScriptCore.JSValueProtect(context.context, self.value)
}
+ /// Creates a JavaScript value of the function type that wraps an async Swift callback.
+ ///
+ /// When called from JavaScript, the function returns a `Promise` that resolves with the
+ /// async callback's return value, or rejects if the callback throws.
+ ///
+ /// - Parameters:
+ /// - context: The execution context to use.
+ /// - callback: The async callback function.
+ #if !SKIP
+ @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
+ public convenience init(newAsyncFunctionIn context: JSContext, callback: @escaping (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue) {
+ self.init(newFunctionIn: context, callback: { ctx, obj, args in
+ guard let promiseParts = ctx.createPromise() else {
+ return JSValue(undefinedIn: ctx)
+ }
+ Task {
+ do {
+ let result = try await callback(ctx, obj, args)
+ _ = try? promiseParts.resolveFunction.call(withArguments: [result])
+ } catch {
+ let errorValue = JSValue(newErrorFromCause: error, in: ctx)
+ _ = try? promiseParts.rejectFunction.call(withArguments: [errorValue])
+ }
+ }
+ return promiseParts.promise
+ })
+ }
+ #endif
+
#if !SKIP
/// Creates a JavaScript `Error` object, as if by invoking the built-in `Error` constructor.
@@ -534,7 +607,7 @@ public class JSValue {
let ctx = self.context
return try context.trying { (exception: ExceptionPtr) in
- guard let result = JavaScriptCore.JSObjectCallAsFunction(ctx.context, self.value, nil, arguments.count, arguments.count == 0 ? nil : args, exception) else {
+ guard let result = JavaScriptCore.JSObjectCallAsFunction(ctx.context, self.value, this?.value, arguments.count, arguments.count == 0 ? nil : args, exception) else {
return JSValue(undefinedIn: ctx)
}
@@ -711,6 +784,27 @@ public func JSValue(string value: String, in context: JSContext) -> JSValue {
defer { JavaScriptCore.JSStringRelease(str) }
return JSValue(jsValueRef: JavaScriptCore.JSValueMakeString(context.context, str), in: context)
}
+
+// workaround for inability to implement this as a convenience constructor due to needing local variables
+@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
+public func JSValue(newAsyncFunctionIn context: JSContext, callback: @escaping (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue) -> JSValue {
+ return JSValue(newFunctionIn: context, callback: { ctx, obj, args in
+ guard let promiseParts = ctx.createPromise() else {
+ return JSValue(undefinedIn: ctx)
+ }
+ let (promise, resolveFunction, rejectFunction) = promiseParts // SKIP 3-tuples do not handle referencing elements by name
+ Task {
+ do {
+ let result = try await callback(ctx, obj, args)
+ _ = try? resolveFunction.call(withArguments: [result])
+ } catch {
+ let errorValue = JSValue(object: String(describing: error), in: ctx)
+ _ = try? rejectFunction.call(withArguments: [errorValue])
+ }
+ }
+ return promise
+ })
+}
#endif
@@ -782,6 +876,10 @@ public struct JSError: Error, CustomStringConvertible {
public typealias JSFunction = (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) throws -> JSValue
public typealias JSPromise = (promise: JSValue, resolveFunction: JSValue, rejectFunction: JSValue)
+/// An async function definition, used when defining async callbacks that return Promises to JavaScript.
+@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
+public typealias JSAsyncFunction = (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue
+
private struct _JSFunctionInfoHandle {
unowned let context: JSContext
let callback: JSFunction
diff --git a/Sources/SkipScript/Skip/skip.yml b/Sources/SkipScript/Skip/skip.yml
index f4bdf00..4efd5b5 100644
--- a/Sources/SkipScript/Skip/skip.yml
+++ b/Sources/SkipScript/Skip/skip.yml
@@ -11,8 +11,6 @@
# contents:
# - block: 'repositories'
# contents:
-# # this is where the android-jsc libraries are hosted
-# - 'maven("https://maven.skip.tools")'
# the blocks to add to the build.gradle.kts
build:
diff --git a/Tests/SkipScriptTests/Skip/AndroidManifest.xml b/Tests/SkipScriptTests/Skip/AndroidManifest.xml
new file mode 100644
index 0000000..970e3d0
--- /dev/null
+++ b/Tests/SkipScriptTests/Skip/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/Tests/SkipScriptTests/SkipContextTests.swift b/Tests/SkipScriptTests/SkipContextTests.swift
index 4169fb6..d607f65 100644
--- a/Tests/SkipScriptTests/SkipContextTests.swift
+++ b/Tests/SkipScriptTests/SkipContextTests.swift
@@ -62,6 +62,40 @@ class SkipContextTests : XCTestCase {
XCTAssertEqual("true12X", ctx.evaluateScript("stringify(true, 1, 2, 'X')")?.toString())
}
+ func testAsyncStringArgsFunctionProperty() async throws {
+ let ctx = JSContext()
+ let asyncStringify = JSValue(newAsyncFunctionIn: ctx) { ctx, obj, args in
+ try await Task.sleep(nanoseconds: 10_000_000) // 10ms delay to simulate async work
+ return JSValue(string: args.compactMap({ $0.toString() }).joined(), in: ctx)
+ }
+
+ ctx.setObject(asyncStringify, forKeyedSubscript: "asyncStringify")
+
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify()"))
+ let resolved = try await ctx.awaitPromise(promise)
+ XCTAssertEqual("", resolved.toString())
+ }
+
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify('')"))
+ let resolved = try await ctx.awaitPromise(promise)
+ XCTAssertEqual("", resolved.toString())
+ }
+
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify('A', 'BC')"))
+ let resolved = try await ctx.awaitPromise(promise)
+ XCTAssertEqual("ABC", resolved.toString())
+ }
+
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify(true, 1, 2, 'X')"))
+ let resolved = try await ctx.awaitPromise(promise)
+ XCTAssertEqual("true12X", resolved.toString())
+ }
+ }
+
func testDoubleArgsFunctionProperty() throws {
let ctx = JSContext()
let sum = JSValue(newFunctionIn: ctx) { ctx, obj, args in
diff --git a/Tests/SkipScriptTests/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift
new file mode 100644
index 0000000..1d7a564
--- /dev/null
+++ b/Tests/SkipScriptTests/SkipScriptletTests.swift
@@ -0,0 +1,356 @@
+// Copyright 2023–2025 Skip
+// SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+import OSLog
+import Foundation
+import SkipScript // this import means we're testing SkipScript.JSContext()
+import XCTest
+
+@available(macOS 11, iOS 14, watchOS 7, tvOS 14, *)
+class SkipScriptletTests : XCTestCase {
+ let logger: Logger = Logger(subsystem: "test", category: "SkipScriptletTests")
+
+ /// Creates a JSContext pre-configured with a `fs` namespace object providing
+ /// cross-platform file system operations: tempDir, writeFile, readFile,
+ /// fileExists, deleteFile, and appendFile.
+ private func makeFileSystemContext() -> JSContext {
+ let ctx = JSContext()
+ let fs = JSValue(newObjectIn: ctx)
+
+ // fs.tempDir() -> string
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ return JSValue(string: FileManager.default.temporaryDirectory.path, in: ctx)
+ }, forKeyedSubscript: "tempDir")
+
+ // fs.writeFile(path, content) -> boolean
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 2 else { return JSValue(bool: false, in: ctx) }
+ let path: String = args[0].toString()
+ let content: String = args[1].toString()
+ do {
+ try content.write(toFile: path, atomically: true, encoding: .utf8)
+ return JSValue(bool: true, in: ctx)
+ } catch {
+ return JSValue(bool: false, in: ctx)
+ }
+ }, forKeyedSubscript: "writeFile")
+
+ // fs.readFile(path) -> string | null
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else { return JSValue(nullIn: ctx) }
+ let path: String = args[0].toString()
+ do {
+ let url = URL(fileURLWithPath: path, isDirectory: false)
+ let content = try String(contentsOf: url, encoding: .utf8)
+ return JSValue(string: content, in: ctx)
+ } catch {
+ return JSValue(nullIn: ctx)
+ }
+ }, forKeyedSubscript: "readFile")
+
+ // fs.fileExists(path) -> boolean
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else { return JSValue(bool: false, in: ctx) }
+ let path: String = args[0].toString()
+ return JSValue(bool: FileManager.default.fileExists(atPath: path), in: ctx)
+ }, forKeyedSubscript: "fileExists")
+
+ // fs.deleteFile(path) -> boolean
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else { return JSValue(bool: false, in: ctx) }
+ let path: String = args[0].toString()
+ do {
+ try FileManager.default.removeItem(atPath: path)
+ return JSValue(bool: true, in: ctx)
+ } catch {
+ return JSValue(bool: false, in: ctx)
+ }
+ }, forKeyedSubscript: "deleteFile")
+
+ // fs.appendFile(path, content) -> boolean
+ fs.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 2 else { return JSValue(bool: false, in: ctx) }
+ let path: String = args[0].toString()
+ let content: String = args[1].toString()
+ do {
+ let url = URL(fileURLWithPath: path, isDirectory: false)
+ if FileManager.default.fileExists(atPath: path) {
+ let existing = try String(contentsOf: url, encoding: .utf8)
+ try (existing + content).write(toFile: path, atomically: true, encoding: .utf8)
+ } else {
+ try content.write(toFile: path, atomically: true, encoding: .utf8)
+ }
+ return JSValue(bool: true, in: ctx)
+ } catch {
+ return JSValue(bool: false, in: ctx)
+ }
+ }, forKeyedSubscript: "appendFile")
+
+ ctx.setObject(fs, forKeyedSubscript: "fs")
+ return ctx
+ }
+
+ /// Creates a JSContext pre-configured with a `net` namespace object providing
+ /// asynchronous network operations: fetch and fetchStatus.
+ private func makeNetworkContext() -> JSContext {
+ let ctx = JSContext()
+ let net = JSValue(newObjectIn: ctx)
+
+ // net.fetch(url) -> Promise
+ net.setObject(JSValue(newAsyncFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else {
+ return JSValue(string: "", in: ctx)
+ }
+ let urlString: String = args[0].toString()
+ guard let url = URL(string: urlString) else {
+ return JSValue(string: "", in: ctx)
+ }
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let body = String(data: data, encoding: .utf8) ?? ""
+ return JSValue(string: body, in: ctx)
+ }, forKeyedSubscript: "fetch")
+
+ // net.fetchStatus(url) -> Promise
+ net.setObject(JSValue(newAsyncFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else {
+ return JSValue(double: -1.0, in: ctx)
+ }
+ let urlString: String = args[0].toString()
+ guard let url = URL(string: urlString) else {
+ return JSValue(double: -1.0, in: ctx)
+ }
+ let (_, response) = try await URLSession.shared.data(from: url)
+ if let httpResponse = response as? HTTPURLResponse {
+ return JSValue(double: Double(httpResponse.statusCode), in: ctx)
+ }
+ return JSValue(double: -1.0, in: ctx)
+ }, forKeyedSubscript: "fetchStatus")
+
+ ctx.setObject(net, forKeyedSubscript: "net")
+ return ctx
+ }
+
+ /// Creates a JSContext pre-configured with a `device` namespace object providing
+ /// cross-platform device and environment information properties and functions.
+ private func makeDeviceInfoContext() -> JSContext {
+ let ctx = JSContext()
+ let device = JSValue(newObjectIn: ctx)
+
+ let isAndroidDevice = isRobolectric || isAndroid
+
+ // Determine OS name at runtime to work on both platforms
+ var osName = "unknown"
+ if isAndroidDevice {
+ osName = "Android"
+ } else {
+ #if os(macOS)
+ osName = "macOS"
+ #elseif os(iOS)
+ osName = "iOS"
+ #elseif os(tvOS)
+ osName = "tvOS"
+ #elseif os(watchOS)
+ osName = "watchOS"
+ #endif
+ }
+
+ // Static properties
+ device.setObject(JSValue(string: osName, in: ctx), forKeyedSubscript: "osName")
+ device.setObject(JSValue(string: ProcessInfo.processInfo.operatingSystemVersionString, in: ctx), forKeyedSubscript: "osVersion")
+ device.setObject(JSValue(double: Double(ProcessInfo.processInfo.processorCount), in: ctx), forKeyedSubscript: "processorCount")
+ device.setObject(JSValue(string: ProcessInfo.processInfo.hostName, in: ctx), forKeyedSubscript: "hostName")
+ device.setObject(JSValue(bool: isAndroidDevice, in: ctx), forKeyedSubscript: "isAndroid")
+ device.setObject(JSValue(string: ProcessInfo.processInfo.globallyUniqueString, in: ctx), forKeyedSubscript: "uniqueId")
+ device.setObject(JSValue(string: FileManager.default.temporaryDirectory.path, in: ctx), forKeyedSubscript: "tempDir")
+
+ // device.getEnv(key) -> string
+ device.setObject(JSValue(newFunctionIn: ctx) { ctx, obj, args in
+ guard args.count >= 1 else { return JSValue(string: "", in: ctx) }
+ let key: String = args[0].toString()
+ let value = ProcessInfo.processInfo.environment[key] ?? ""
+ return JSValue(string: value, in: ctx)
+ }, forKeyedSubscript: "getEnv")
+
+ ctx.setObject(device, forKeyedSubscript: "device")
+ return ctx
+ }
+
+ // MARK: - File System Tests
+
+ func testFileSystemFunctions() async throws {
+ let ctx = makeFileSystemContext()
+
+ // Test: complete file lifecycle from JavaScript
+ let result = ctx.evaluateScript("""
+ var dir = fs.tempDir();
+ var testFile = dir + '/skip_scriptlet_fs_' + Date.now() + '.txt';
+
+ // Write a new file
+ if (!fs.writeFile(testFile, 'Hello from JavaScript!'))
+ throw new Error('writeFile failed to ' + testFile);
+
+ // Verify it exists
+ if (!fs.fileExists(testFile))
+ throw new Error('File should exist after writing');
+
+ // Read it back and verify content
+ var content = fs.readFile(testFile);
+ if (content !== 'Hello from JavaScript!')
+ throw new Error('Content mismatch: ' + content);
+
+ // Append to the file
+ if (!fs.appendFile(testFile, ' More text.'))
+ throw new Error('appendFile failed');
+
+ // Read the appended content
+ var updated = fs.readFile(testFile);
+ if (updated !== 'Hello from JavaScript! More text.')
+ throw new Error('Appended content mismatch: ' + updated);
+
+ // FIXME: overwrite does not truncate: https://github.com/skiptools/skip-foundation/pull/92
+ if (!fs.deleteFile(testFile))
+ throw new Error('deleteFile failed');
+
+ // Overwrite the file with new content
+ if (!fs.writeFile(testFile, 'Replaced.'))
+ throw new Error('overwrite writeFile failed');
+ var replaced = fs.readFile(testFile);
+ if (replaced !== 'Replaced.')
+ throw new Error('Replaced content mismatch: ' + replaced);
+
+ // Delete the file
+ if (!fs.deleteFile(testFile))
+ throw new Error('deleteFile failed');
+
+ // Verify it is gone
+ if (fs.fileExists(testFile))
+ throw new Error('File should not exist after deletion');
+
+ // Reading a deleted file should return null
+ var gone = fs.readFile(testFile);
+ if (gone !== null)
+ throw new Error('readFile of deleted file should be null');
+
+ 'success';
+ """)
+ XCTAssertNil(ctx.exception, "JS exception: \(ctx.exception?.toString() ?? "")")
+ XCTAssertEqual("success", result?.toString())
+ }
+
+ // MARK: - Async Network Tests
+
+ func testAsyncNetworkFunctions() async throws {
+ let ctx = makeNetworkContext()
+
+ // Test 1: Fetch a well-known page and verify its content
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("net.fetch('https://example.com')"))
+ XCTAssertNil(ctx.exception, "JS exception from net.fetch: \(ctx.exception?.toString() ?? "")")
+ let result = try await ctx.awaitPromise(promise)
+ let body: String = result.toString()
+ XCTAssertTrue(body.contains("Example Domain"), "Response should contain 'Example Domain'")
+ }
+
+ // Test 2: Verify HTTP status code
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("net.fetchStatus('https://example.com')"))
+ XCTAssertNil(ctx.exception, "JS exception from net.fetchStatus: \(ctx.exception?.toString() ?? "")")
+ let result = try await ctx.awaitPromise(promise)
+ XCTAssertEqual(200.0, result.toDouble())
+ }
+
+ // Test 3: Chain async calls in JavaScript using an async IIFE
+ do {
+ let promise = try XCTUnwrap(ctx.evaluateScript("""
+ (async function() {
+ var body = await net.fetch('https://example.com');
+ var status = await net.fetchStatus('https://example.com');
+ var result = {};
+ result.hasTitle = body.indexOf('') !== -1;
+ result.hasBody = body.length > 0;
+ result.statusOk = status === 200;
+ return result.hasTitle + '|' + result.hasBody + '|' + result.statusOk;
+ })()
+ """))
+ XCTAssertNil(ctx.exception, "JS exception from async IIFE: \(ctx.exception?.toString() ?? "")")
+ let result = try await ctx.awaitPromise(promise)
+ XCTAssertEqual("true|true|true", result.toString())
+ }
+ }
+
+ // MARK: - Device Info Tests
+
+ func testDeviceInfoFuctions() async throws {
+ let ctx = makeDeviceInfoContext()
+
+ // Test: query and validate device properties from JavaScript
+ let result = ctx.evaluateScript("""
+ // Verify all properties exist and have expected types
+ if (typeof device.osName !== 'string' || device.osName.length === 0)
+ throw new Error('osName should be a non-empty string, got: ' + device.osName);
+
+ if (typeof device.osVersion !== 'string' || device.osVersion.length === 0)
+ throw new Error('osVersion should be a non-empty string, got: ' + device.osVersion);
+
+ if (typeof device.processorCount !== 'number' || device.processorCount < 1)
+ throw new Error('processorCount should be >= 1, got: ' + device.processorCount);
+
+ if (typeof device.hostName !== 'string')
+ throw new Error('hostName should be a string, got: ' + typeof device.hostName);
+
+ if (typeof device.isAndroid !== 'boolean')
+ throw new Error('isAndroid should be a boolean, got: ' + typeof device.isAndroid);
+
+ if (typeof device.uniqueId !== 'string' || device.uniqueId.length === 0)
+ throw new Error('uniqueId should be a non-empty string');
+
+ if (typeof device.tempDir !== 'string' || device.tempDir.length === 0)
+ throw new Error('tempDir should be a non-empty string');
+
+ // Platform-specific validation
+ if (device.isAndroid) {
+ if (device.osName !== 'Android')
+ throw new Error('osName should be Android on Android, got: ' + device.osName);
+ } else {
+ var knownPlatforms = ['macOS', 'iOS', 'tvOS', 'watchOS'];
+ if (knownPlatforms.indexOf(device.osName) === -1)
+ throw new Error('Unexpected osName on Apple platform: ' + device.osName);
+ }
+
+ // Test the getEnv function returns a string
+ var envResult = device.getEnv('PATH');
+ if (typeof envResult !== 'string')
+ throw new Error('getEnv should return a string, got: ' + typeof envResult);
+
+ 'success';
+ """)
+ XCTAssertNil(ctx.exception, "JS exception: \(ctx.exception?.toString() ?? "")")
+ XCTAssertEqual("success", result?.toString())
+
+ // Test: use device info to construct a platform summary in JS
+ let summary = ctx.evaluateScript("""
+ var parts = [];
+ parts.push(device.osName + ' ' + device.osVersion);
+ parts.push(device.processorCount + ' cores');
+ parts.push('host: ' + device.hostName);
+ parts.join(', ');
+ """)
+ XCTAssertNil(ctx.exception, "JS exception: \(ctx.exception?.toString() ?? "")")
+ let summaryStr = try XCTUnwrap(summary?.toString())
+ logger.info("Device summary from JS: \(summaryStr)")
+ XCTAssertTrue(summaryStr.contains("cores"), "Summary should mention cores: \(summaryStr)")
+
+ // Test: use device info for conditional logic in JS
+ let conditional = ctx.evaluateScript("""
+ var greeting;
+ if (device.isAndroid) {
+ greeting = 'Hello from Android ' + device.osVersion;
+ } else {
+ greeting = 'Hello from ' + device.osName + ' ' + device.osVersion;
+ }
+ greeting;
+ """)
+ XCTAssertNil(ctx.exception, "JS exception: \(ctx.exception?.toString() ?? "")")
+ let greetingStr = try XCTUnwrap(conditional?.toString())
+ XCTAssertTrue(greetingStr.hasPrefix("Hello from"), "Greeting should start with 'Hello from': \(greetingStr)")
+ }
+}