|
1 | 1 | import XCTest |
2 | 2 | import UIKit |
3 | 3 | import CoreGraphics |
| 4 | +import Darwin |
4 | 5 |
|
5 | 6 | final class HelloCodenameOneUITests: XCTestCase { |
6 | 7 | private var app: XCUIApplication! |
@@ -41,6 +42,7 @@ final class HelloCodenameOneUITests: XCTestCase { |
41 | 42 | print("CN1SS:INFO:ui_test_launch_arguments=\(app.launchArguments.joined(separator: " "))") |
42 | 43 |
|
43 | 44 | ensureAppLaunched() |
| 45 | + triggerCodenameOneMainIfPossible() |
44 | 46 | waitForStableFrame() |
45 | 47 | } |
46 | 48 |
|
@@ -133,6 +135,7 @@ final class HelloCodenameOneUITests: XCTestCase { |
133 | 135 |
|
134 | 136 | private func captureScreenshot(named name: String) throws { |
135 | 137 | ensureAppLaunched() |
| 138 | + triggerCodenameOneMainIfPossible() |
136 | 139 | waitForStableFrame() |
137 | 140 | let result = pollForRenderableContent(label: name, timeout: 30, poll: 0.6) |
138 | 141 | let shot = result.screenshot |
@@ -283,6 +286,14 @@ final class HelloCodenameOneUITests: XCTestCase { |
283 | 286 | return nil |
284 | 287 | } |
285 | 288 |
|
| 289 | + private func triggerCodenameOneMainIfPossible() { |
| 290 | + guard let bundleID = resolveBundleIdentifier(), !bundleID.isEmpty else { |
| 291 | + print("CN1SS:WARN:codenameone_main_skipped reason=no_bundle_identifier") |
| 292 | + return |
| 293 | + } |
| 294 | + CodenameOneMainInvoker.shared.invokeIfNeeded(app: app, bundleIdentifier: bundleID) |
| 295 | + } |
| 296 | + |
286 | 297 | private func sanitizeTestName(_ name: String) -> String { |
287 | 298 | let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") |
288 | 299 | let underscore: UnicodeScalar = "_" |
@@ -361,3 +372,134 @@ final class HelloCodenameOneUITests: XCTestCase { |
361 | 372 | print("\(prefix):END:\(name)") |
362 | 373 | } |
363 | 374 | } |
| 375 | + |
| 376 | +private final class CodenameOneMainInvoker { |
| 377 | + static let shared = CodenameOneMainInvoker() |
| 378 | + |
| 379 | + private let queue = DispatchQueue(label: "codenameone.main.invoker") |
| 380 | + private var invokedBundles: Set<String> = [] |
| 381 | + private var handles: [String: UnsafeMutableRawPointer] = [:] |
| 382 | + |
| 383 | + private init() {} |
| 384 | + |
| 385 | + func invokeIfNeeded(app: XCUIApplication, bundleIdentifier: String) { |
| 386 | + var alreadyInvoked = false |
| 387 | + queue.sync { |
| 388 | + alreadyInvoked = invokedBundles.contains(bundleIdentifier) |
| 389 | + } |
| 390 | + if alreadyInvoked { |
| 391 | + return |
| 392 | + } |
| 393 | + |
| 394 | + guard let context = prepareInvocation(app: app, bundleIdentifier: bundleIdentifier) else { |
| 395 | + return |
| 396 | + } |
| 397 | + |
| 398 | + context.invoke() |
| 399 | + |
| 400 | + queue.sync { |
| 401 | + invokedBundles.insert(bundleIdentifier) |
| 402 | + handles[bundleIdentifier] = context.handle |
| 403 | + } |
| 404 | + print("CN1SS:INFO:codenameone_main_invoked bundle=\(bundleIdentifier)") |
| 405 | + } |
| 406 | + |
| 407 | + private func prepareInvocation(app: XCUIApplication, bundleIdentifier: String) -> InvocationContext? { |
| 408 | + guard let container = locateAppContainer(app: app, bundleIdentifier: bundleIdentifier) else { |
| 409 | + print("CN1SS:WARN:codenameone_main_skipped reason=container_missing bundle=\(bundleIdentifier)") |
| 410 | + return nil |
| 411 | + } |
| 412 | + |
| 413 | + guard let executable = readExecutableName(appContainer: container) else { |
| 414 | + print("CN1SS:WARN:codenameone_main_skipped reason=executable_missing bundle=\(bundleIdentifier)") |
| 415 | + return nil |
| 416 | + } |
| 417 | + |
| 418 | + let binaryPath = (container as NSString).appendingPathComponent(executable) |
| 419 | + guard let handle = dlopen(binaryPath, RTLD_NOW | RTLD_GLOBAL) else { |
| 420 | + if let error = dlerror() { |
| 421 | + print("CN1SS:WARN:codenameone_main_skipped reason=dlopen_failed bundle=\(bundleIdentifier) error=\(String(cString: error))") |
| 422 | + } else { |
| 423 | + print("CN1SS:WARN:codenameone_main_skipped reason=dlopen_failed bundle=\(bundleIdentifier)") |
| 424 | + } |
| 425 | + return nil |
| 426 | + } |
| 427 | + |
| 428 | + guard let initPtr = dlsym(handle, "initConstantPool") else { |
| 429 | + print("CN1SS:WARN:codenameone_main_skipped reason=missing_initConstantPool bundle=\(bundleIdentifier)") |
| 430 | + return nil |
| 431 | + } |
| 432 | + |
| 433 | + guard let threadPtr = dlsym(handle, "getThreadLocalData") else { |
| 434 | + print("CN1SS:WARN:codenameone_main_skipped reason=missing_getThreadLocalData bundle=\(bundleIdentifier)") |
| 435 | + return nil |
| 436 | + } |
| 437 | + |
| 438 | + let mainSymbol = "com_codenameone_examples_HelloCodenameOne_main___java_lang_String_1ARRAY" |
| 439 | + guard let mainPtr = dlsym(handle, mainSymbol) else { |
| 440 | + print("CN1SS:WARN:codenameone_main_skipped reason=missing_main_symbol bundle=\(bundleIdentifier) symbol=\(mainSymbol)") |
| 441 | + return nil |
| 442 | + } |
| 443 | + |
| 444 | + let initFn = unsafeBitCast(initPtr, to: InvocationContext.InitConstantPoolFn.self) |
| 445 | + let threadFn = unsafeBitCast(threadPtr, to: InvocationContext.GetThreadLocalDataFn.self) |
| 446 | + let mainFn = unsafeBitCast(mainPtr, to: InvocationContext.CodenameOneMainFn.self) |
| 447 | + |
| 448 | + return InvocationContext(handle: handle, initConstantPool: initFn, getThreadLocalData: threadFn, mainFunction: mainFn) |
| 449 | + } |
| 450 | + |
| 451 | + private func locateAppContainer(app: XCUIApplication, bundleIdentifier: String) -> String? { |
| 452 | + do { |
| 453 | + if let bundleURL = try app.value(forKey: "bundleURL") as? URL { |
| 454 | + return bundleURL.path |
| 455 | + } |
| 456 | + } catch { |
| 457 | + print("CN1SS:WARN:codenameone_main_kvc_failed key=bundleURL bundle=\(bundleIdentifier) error=\(error)") |
| 458 | + } |
| 459 | + |
| 460 | + do { |
| 461 | + if let bundlePath = try app.value(forKey: "bundlePath") as? String { |
| 462 | + return bundlePath |
| 463 | + } |
| 464 | + } catch { |
| 465 | + print("CN1SS:WARN:codenameone_main_kvc_failed key=bundlePath bundle=\(bundleIdentifier) error=\(error)") |
| 466 | + } |
| 467 | + |
| 468 | + if let fallback = Bundle.main.infoDictionary?["CFBundleExecutable"] as? String { |
| 469 | + print("CN1SS:WARN:codenameone_main_skipped reason=bundle_path_unavailable bundle=\(bundleIdentifier) fallbackExecutable=\(fallback)") |
| 470 | + } else { |
| 471 | + print("CN1SS:WARN:codenameone_main_skipped reason=bundle_path_unavailable bundle=\(bundleIdentifier)") |
| 472 | + } |
| 473 | + return nil |
| 474 | + } |
| 475 | + |
| 476 | + private func readExecutableName(appContainer: String) -> String? { |
| 477 | + let infoPath = (appContainer as NSString).appendingPathComponent("Info.plist") |
| 478 | + guard let info = NSDictionary(contentsOfFile: infoPath) as? [String: Any] else { |
| 479 | + print("CN1SS:WARN:codenameone_main_skipped reason=info_plist_unreadable path=\(infoPath)") |
| 480 | + return nil |
| 481 | + } |
| 482 | + guard let executable = info["CFBundleExecutable"] as? String, !executable.isEmpty else { |
| 483 | + print("CN1SS:WARN:codenameone_main_skipped reason=cfbundleexecutablenotfound path=\(infoPath)") |
| 484 | + return nil |
| 485 | + } |
| 486 | + return executable |
| 487 | + } |
| 488 | + |
| 489 | + private struct InvocationContext { |
| 490 | + typealias InitConstantPoolFn = @convention(c) () -> Void |
| 491 | + typealias GetThreadLocalDataFn = @convention(c) () -> UnsafeMutableRawPointer? |
| 492 | + typealias CodenameOneMainFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Void |
| 493 | + |
| 494 | + let handle: UnsafeMutableRawPointer |
| 495 | + let initConstantPool: InitConstantPoolFn |
| 496 | + let getThreadLocalData: GetThreadLocalDataFn |
| 497 | + let mainFunction: CodenameOneMainFn |
| 498 | + |
| 499 | + func invoke() { |
| 500 | + initConstantPool() |
| 501 | + let threadState = getThreadLocalData() |
| 502 | + mainFunction(threadState, nil) |
| 503 | + } |
| 504 | + } |
| 505 | +} |
0 commit comments