From 78be5f6b5a5b28e406ef04e656b39946cfe50dd0 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 12 Feb 2026 19:33:42 -0500 Subject: [PATCH 1/8] Support binding async functions --- Sources/SkipScript/JSContext.swift | 85 +++++++++++++++++++- Sources/SkipScript/Skip/skip.yml | 2 +- Tests/SkipScriptTests/SkipContextTests.swift | 36 +++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/Sources/SkipScript/JSContext.swift b/Sources/SkipScript/JSContext.swift index ced7f83..60dca4f 100644 --- a/Sources/SkipScript/JSContext.swift +++ b/Sources/SkipScript/JSContext.swift @@ -139,6 +139,54 @@ public class JSContext { JavaScriptCore.JSGarbageCollect(context) } + #if !SKIP + + /// 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) + } + } + } + + #endif + } @@ -278,6 +326,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 +611,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) } @@ -782,6 +859,12 @@ 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) +#if !SKIP +/// 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 +#endif + 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..1f44ebe 100644 --- a/Sources/SkipScript/Skip/skip.yml +++ b/Sources/SkipScript/Skip/skip.yml @@ -12,7 +12,7 @@ # - block: 'repositories' # contents: # # this is where the android-jsc libraries are hosted -# - 'maven("https://maven.skip.tools")' +# - 'maven("https://maven.skip.dev")' # the blocks to add to the build.gradle.kts build: diff --git a/Tests/SkipScriptTests/SkipContextTests.swift b/Tests/SkipScriptTests/SkipContextTests.swift index 4169fb6..0f99050 100644 --- a/Tests/SkipScriptTests/SkipContextTests.swift +++ b/Tests/SkipScriptTests/SkipContextTests.swift @@ -62,6 +62,42 @@ class SkipContextTests : XCTestCase { XCTAssertEqual("true12X", ctx.evaluateScript("stringify(true, 1, 2, 'X')")?.toString()) } + #if !SKIP + 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()) + } + } + #endif + func testDoubleArgsFunctionProperty() throws { let ctx = JSContext() let sum = JSValue(newFunctionIn: ctx) { ctx, obj, args in From d69cdc1399755685912770a671f8fc6458ec7c68 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 12 Feb 2026 20:03:15 -0500 Subject: [PATCH 2/8] Add SKIP support for async functions --- Sources/SkipScript/JSContext.swift | 27 +++++++++++++++----- Tests/SkipScriptTests/SkipContextTests.swift | 2 -- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/SkipScript/JSContext.swift b/Sources/SkipScript/JSContext.swift index 60dca4f..b97ce25 100644 --- a/Sources/SkipScript/JSContext.swift +++ b/Sources/SkipScript/JSContext.swift @@ -139,8 +139,6 @@ public class JSContext { JavaScriptCore.JSGarbageCollect(context) } - #if !SKIP - /// 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 @@ -185,8 +183,6 @@ public class JSContext { } } - #endif - } @@ -788,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 @@ -859,11 +876,9 @@ 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) -#if !SKIP /// 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 -#endif private struct _JSFunctionInfoHandle { unowned let context: JSContext diff --git a/Tests/SkipScriptTests/SkipContextTests.swift b/Tests/SkipScriptTests/SkipContextTests.swift index 0f99050..d607f65 100644 --- a/Tests/SkipScriptTests/SkipContextTests.swift +++ b/Tests/SkipScriptTests/SkipContextTests.swift @@ -62,7 +62,6 @@ class SkipContextTests : XCTestCase { XCTAssertEqual("true12X", ctx.evaluateScript("stringify(true, 1, 2, 'X')")?.toString()) } - #if !SKIP func testAsyncStringArgsFunctionProperty() async throws { let ctx = JSContext() let asyncStringify = JSValue(newAsyncFunctionIn: ctx) { ctx, obj, args in @@ -96,7 +95,6 @@ class SkipContextTests : XCTestCase { XCTAssertEqual("true12X", resolved.toString()) } } - #endif func testDoubleArgsFunctionProperty() throws { let ctx = JSContext() From 4dab20c46305b5b482e5f44f5d90631609622eeb Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 12 Feb 2026 21:11:54 -0500 Subject: [PATCH 3/8] Update skip.yml [skip ci] --- Sources/SkipScript/Skip/skip.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/SkipScript/Skip/skip.yml b/Sources/SkipScript/Skip/skip.yml index 1f44ebe..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.dev")' # the blocks to add to the build.gradle.kts build: From 7c810b2197d39cc840a919dfb5ae837fa4264dca Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 12 Feb 2026 21:58:54 -0500 Subject: [PATCH 4/8] Add demonstration test cases for implementing scriptlet functionality --- .../SkipScriptTests/Skip/AndroidManifest.xml | 11 + .../SkipScriptTests/SkipScriptletTests.swift | 355 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 Tests/SkipScriptTests/Skip/AndroidManifest.xml create mode 100644 Tests/SkipScriptTests/SkipScriptletTests.swift 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/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift new file mode 100644 index 0000000..85a13ea --- /dev/null +++ b/Tests/SkipScriptTests/SkipScriptletTests.swift @@ -0,0 +1,355 @@ +// 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 { + // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 + try content.write(toFile: path, atomically: false, 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) + // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 + try (existing + content).write(toFile: path, atomically: false, encoding: .utf8) + } else { + // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 + try content.write(toFile: path, atomically: false, 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 = ProcessInfo.processInfo.environment["ANDROID_ROOT"] != nil + + // 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'); + + // 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); + + // 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)") + } +} From a610dde517f92b66ff05f2381ff7195302c48be3 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux <mwp1@cornell.edu> Date: Thu, 12 Feb 2026 22:05:55 -0500 Subject: [PATCH 5/8] Update test case to delete file before overwriting due to https://github.com/skiptools/skip-foundation/pull/92 --- Tests/SkipScriptTests/SkipScriptletTests.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Tests/SkipScriptTests/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift index 85a13ea..810bdc9 100644 --- a/Tests/SkipScriptTests/SkipScriptletTests.swift +++ b/Tests/SkipScriptTests/SkipScriptletTests.swift @@ -27,8 +27,7 @@ class SkipScriptletTests : XCTestCase { let path: String = args[0].toString() let content: String = args[1].toString() do { - // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 - try content.write(toFile: path, atomically: false, encoding: .utf8) + try content.write(toFile: path, atomically: true, encoding: .utf8) return JSValue(bool: true, in: ctx) } catch { return JSValue(bool: false, in: ctx) @@ -76,11 +75,9 @@ class SkipScriptletTests : XCTestCase { let url = URL(fileURLWithPath: path, isDirectory: false) if FileManager.default.fileExists(atPath: path) { let existing = try String(contentsOf: url, encoding: .utf8) - // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 - try (existing + content).write(toFile: path, atomically: false, encoding: .utf8) + try (existing + content).write(toFile: path, atomically: true, encoding: .utf8) } else { - // TODO: atomically: true needs https://github.com/skiptools/skip-foundation/pull/92 - try content.write(toFile: path, atomically: false, encoding: .utf8) + try content.write(toFile: path, atomically: true, encoding: .utf8) } return JSValue(bool: true, in: ctx) } catch { @@ -209,6 +206,10 @@ class SkipScriptletTests : XCTestCase { 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'); From f69080e1dcc2b6e9eb96fd26ee17badf5b15f4f0 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux <mwp1@cornell.edu> Date: Thu, 12 Feb 2026 23:19:56 -0500 Subject: [PATCH 6/8] Update check for isAndroid --- Tests/SkipScriptTests/SkipScriptletTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SkipScriptTests/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift index 810bdc9..b7fc4ac 100644 --- a/Tests/SkipScriptTests/SkipScriptletTests.swift +++ b/Tests/SkipScriptTests/SkipScriptletTests.swift @@ -135,7 +135,7 @@ class SkipScriptletTests : XCTestCase { let ctx = JSContext() let device = JSValue(newObjectIn: ctx) - let isAndroidDevice = ProcessInfo.processInfo.environment["ANDROID_ROOT"] != nil + let isAndroidDevice = isRobolectric || isAndroid // Determine OS name at runtime to work on both platforms var osName = "unknown" From dad98676a14be6403c4e4e1c916549d8ea326eb4 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux <mwp1@cornell.edu> Date: Thu, 12 Feb 2026 23:39:48 -0500 Subject: [PATCH 7/8] Disable testFileSystemFunctions due to Robolectric failures --- Tests/SkipScriptTests/SkipScriptletTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SkipScriptTests/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift index b7fc4ac..0d93a98 100644 --- a/Tests/SkipScriptTests/SkipScriptletTests.swift +++ b/Tests/SkipScriptTests/SkipScriptletTests.swift @@ -176,7 +176,7 @@ class SkipScriptletTests : XCTestCase { // MARK: - File System Tests - func testFileSystemFunctions() async throws { + func XXXtestFileSystemFunctions() async throws { let ctx = makeFileSystemContext() // Test: complete file lifecycle from JavaScript @@ -186,7 +186,7 @@ class SkipScriptletTests : XCTestCase { // Write a new file if (!fs.writeFile(testFile, 'Hello from JavaScript!')) - throw new Error('writeFile failed'); + throw new Error('writeFile failed to ' + testFile); // Verify it exists if (!fs.fileExists(testFile)) From bc75dca6bdd0ef8674fc57eaefeab95a32f0bb07 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux <mwp1@cornell.edu> Date: Thu, 12 Feb 2026 23:40:22 -0500 Subject: [PATCH 8/8] Restore testFileSystemFunctions --- Tests/SkipScriptTests/SkipScriptletTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SkipScriptTests/SkipScriptletTests.swift b/Tests/SkipScriptTests/SkipScriptletTests.swift index 0d93a98..1d7a564 100644 --- a/Tests/SkipScriptTests/SkipScriptletTests.swift +++ b/Tests/SkipScriptTests/SkipScriptletTests.swift @@ -176,7 +176,7 @@ class SkipScriptletTests : XCTestCase { // MARK: - File System Tests - func XXXtestFileSystemFunctions() async throws { + func testFileSystemFunctions() async throws { let ctx = makeFileSystemContext() // Test: complete file lifecycle from JavaScript