From 644ca8a215ad544767279a465e2a1e3766270beb Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Wed, 4 Feb 2026 23:04:40 +0100 Subject: [PATCH 1/6] Add Unit Test --- .../xcshareddata/xcschemes/Atlantis.xcscheme | 10 + Package.swift | 5 + Sources/Atlantis.swift | 15 +- Tests/atlantisTests/atlantisTests.swift | 296 ++++++++++++++++-- 4 files changed, 305 insertions(+), 21 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Atlantis.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Atlantis.xcscheme index e80f585..672cda9 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Atlantis.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Atlantis.xcscheme @@ -56,6 +56,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Void)? + + func start(_ config: Configuration) { + // No-op: avoid Bonjour/network in tests. + } + + func stop() { + // No-op + } + + func send(package: Serializable) { + guard let data = package.toData(), + let envelope = try? JSONDecoder().decode(TestMessageEnvelope.self, from: data) else { + return + } + queue.async { + self.messages.append(envelope) + } + guard envelope.messageType == .traffic, + let content = envelope.content, + let traffic = try? JSONDecoder().decode(TrafficPackage.self, from: content) else { + return + } + onTrafficPackage?(traffic) + } + + func drainMessages() -> [TestMessageEnvelope] { + queue.sync { messages } + } +} + +final class URLSessionSwizzleTests: XCTestCase { + private let baseURL = URL(string: "https://httpbin.proxyman.app")! + private var transporter: TestTransporter! + + override func setUp() { + super.setUp() + transporter = TestTransporter() + Atlantis.setIsRunningOniOSPlayground(true) + Atlantis.setEnableTransportLayer(true) + Atlantis.setTransporterForTesting(transporter) + Atlantis.start() + } + + override func tearDown() { + Atlantis.stop() + transporter = nil + super.tearDown() + } + + func testSelectorExistenceForSwizzledAPIs() { + let sessionClass = NSClassFromString("__NSCFURLLocalSessionConnection") + ?? NSClassFromString("__NSCFURLSessionConnection") + XCTAssertNotNil(sessionClass) + if let sessionClass = sessionClass { + let responseSelector: Selector + if #available(iOS 16.0, tvOS 16.0, *) { + responseSelector = NSSelectorFromString("_didReceiveResponse:sniff:") + } else if #available(iOS 13.0, tvOS 13.0, *) { + responseSelector = NSSelectorFromString("_didReceiveResponse:sniff:rewrite:") + } else { + responseSelector = NSSelectorFromString("_didReceiveResponse:sniff:") + } + assertSelectorExists(baseClass: sessionClass, selector: responseSelector, name: "URLSession response") + assertSelectorExists(baseClass: sessionClass, selector: NSSelectorFromString("_didReceiveData:"), name: "URLSession data") + assertSelectorExists(baseClass: sessionClass, selector: NSSelectorFromString("_didFinishWithError:"), name: "URLSession complete") + } + + let resumeClass: AnyClass? = { + if !ProcessInfo.processInfo.responds(to: #selector(getter: ProcessInfo.operatingSystemVersion)) { + return NSClassFromString("__NSCFLocalSessionTask") + } + let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + if majorVersion < 9 || majorVersion >= 14 { + return URLSessionTask.self + } + return NSClassFromString("__NSCFURLSessionTask") + }() + XCTAssertNotNil(resumeClass) + if let resumeClass = resumeClass { + assertSelectorExists(baseClass: resumeClass, selector: NSSelectorFromString("resume"), name: "URLSession resume") + } + + let urlSessionClass: AnyClass = URLSession.self + assertSelectorExists(baseClass: urlSessionClass, selector: NSSelectorFromString("uploadTaskWithRequest:fromFile:"), name: "upload from file") + assertSelectorExists(baseClass: urlSessionClass, selector: NSSelectorFromString("uploadTaskWithRequest:fromFile:completionHandler:"), name: "upload from file + completion") + assertSelectorExists(baseClass: urlSessionClass, selector: NSSelectorFromString("uploadTaskWithRequest:fromData:"), name: "upload from data") + assertSelectorExists(baseClass: urlSessionClass, selector: NSSelectorFromString("uploadTaskWithRequest:fromData:completionHandler:"), name: "upload from data + completion") + + let webSocketClass = NSClassFromString("__NSURLSessionWebSocketTask") + XCTAssertNotNil(webSocketClass) + if let webSocketClass = webSocketClass { + assertSelectorExists(baseClass: webSocketClass, selector: NSSelectorFromString("sendMessage:completionHandler:"), name: "websocket send") + assertSelectorExists(baseClass: webSocketClass, selector: NSSelectorFromString("receiveMessageWithCompletionHandler:"), name: "websocket receive") + assertSelectorExists(baseClass: webSocketClass, selector: NSSelectorFromString("sendPingWithPongReceiveHandler:"), name: "websocket ping/pong") + assertSelectorExists(baseClass: webSocketClass, selector: NSSelectorFromString("cancelWithCloseCode:reason:"), name: "websocket cancel") + } + } + + func testGetRequestCaptured() { + let url = baseURL.appendingPathComponent("get") + let package = waitForTrafficPackage(matching: { package in + package.request.method == "GET" && package.request.url.contains("/get") + }) { + let session = makeSession() + let task = session.dataTask(with: url) + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.method, "GET") + XCTAssertTrue(package.request.url.contains("/get")) + XCTAssertFalse(package.responseBodyData.isEmpty) + } + + func testPostRequestCaptured() { + let url = baseURL.appendingPathComponent("post") + let body = "hello-atlantis".data(using: .utf8)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let package = waitForTrafficPackage(matching: { package in + package.request.method == "POST" && package.request.url.contains("/post") + }) { + let session = makeSession() + let task = session.dataTask(with: request) + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.body, body) + XCTAssertFalse(package.responseBodyData.isEmpty) + } + + func testDownloadRequestCaptured() { + let url = baseURL.appendingPathComponent("bytes/32") + let package = waitForTrafficPackage(matching: { package in + package.request.method == "GET" && package.request.url.contains("/bytes/32") + }) { + let session = makeSession() + let task = session.downloadTask(with: url) + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertFalse(package.responseBodyData.isEmpty) + } + + func testUploadFromDataCaptured() { + let url = baseURL.appendingPathComponent("post") + let body = "upload-data-body".data(using: .utf8)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let package = waitForTrafficPackage(matching: { package in + package.request.method == "POST" && package.request.url.contains("/post") + }) { + let session = makeSession() + let task = session.uploadTask(with: request, from: body) + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.body, body) + } + + func testUploadFromDataWithCompletionCaptured() { + let url = baseURL.appendingPathComponent("post") + let body = "upload-data-body-completion".data(using: .utf8)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let package = waitForTrafficPackage(matching: { package in + package.request.method == "POST" && package.request.url.contains("/post") + }) { + let session = makeSession() + let task = session.uploadTask(with: request, from: body) { _, _, _ in } + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.body, body) + } + + func testUploadFromFileCaptured() { + let url = baseURL.appendingPathComponent("post") + let body = "upload-file-body".data(using: .utf8)! + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? body.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let package = waitForTrafficPackage(matching: { package in + package.request.method == "POST" && package.request.url.contains("/post") + }) { + let session = makeSession() + let task = session.uploadTask(with: request, fromFile: fileURL) + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.body, body) + } + + func testUploadFromFileWithCompletionCaptured() { + let url = baseURL.appendingPathComponent("post") + let body = "upload-file-body-completion".data(using: .utf8)! + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? body.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let package = waitForTrafficPackage(matching: { package in + package.request.method == "POST" && package.request.url.contains("/post") + }) { + let session = makeSession() + let task = session.uploadTask(with: request, fromFile: fileURL) { _, _, _ in } + task.resume() + } + assertPackageHasSuccessResponse(package) + XCTAssertEqual(package.request.body, body) + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + return URLSession(configuration: config) + } + + private func waitForTrafficPackage(matching predicate: @escaping (TrafficPackage) -> Bool, + action: () -> Void) -> TrafficPackage { + let expectation = expectation(description: "Wait for traffic package") + var capturedPackage: TrafficPackage? + transporter.onTrafficPackage = { package in + guard predicate(package) else { return } + capturedPackage = package + expectation.fulfill() + } + action() + wait(for: [expectation], timeout: 30) + return capturedPackage! + } + + private func assertPackageHasSuccessResponse(_ package: TrafficPackage, + file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual(package.response?.statusCode, 200, file: file, line: line) + } + + private func assertSelectorExists(baseClass: AnyClass, + selector: Selector, + name: String, + file: StaticString = #filePath, + line: UInt = #line) { + let method = class_getInstanceMethod(baseClass, selector) + XCTAssertNotNil(method, "Missing selector: \(name)", file: file, line: line) + XCTAssertTrue(baseClass.instancesRespond(to: selector), "Selector not implemented: \(name)", file: file, line: line) + } } From 050867f0f7d7806598411474c032e20871f606f5 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Wed, 4 Feb 2026 23:06:29 +0100 Subject: [PATCH 2/6] Update main.yml --- .github/workflows/main.yml | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 78bc1fc..5066f40 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,12 +9,39 @@ on: - main jobs: - build: - runs-on: macos-latest + build-and-test: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - xcode: "16.2" + ios: "18.2" + device: "iPhone 16" + - xcode: "26.0" + ios: "26.0" + device: "iPhone 16" steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Build - run: swift build \ No newline at end of file + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer + + - name: Show Xcode and Swift version + run: | + xcodebuild -version + swift --version + + - name: List available simulators + run: xcrun simctl list devices available + + - name: Build and Test on iOS ${{ matrix.ios }} + run: | + xcodebuild test \ + -scheme Atlantis \ + -destination "platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.ios }}" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + | xcpretty --color || exit ${PIPESTATUS[0]} \ No newline at end of file From 820f62a2ee56625019f116d0458cff53f2bdca60 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Wed, 4 Feb 2026 23:10:27 +0100 Subject: [PATCH 3/6] Update main.yml --- .github/workflows/main.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5066f40..703d72c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,6 +37,14 @@ jobs: - name: List available simulators run: xcrun simctl list devices available + - name: Prepare for Swift Package build + run: | + # Move xcodeproj out of the way so xcodebuild uses Package.swift + mv Atlantis.xcodeproj Atlantis.xcodeproj.bak + + - name: List schemes + run: xcodebuild -list + - name: Build and Test on iOS ${{ matrix.ios }} run: | xcodebuild test \ From b370a9f7f199bed116b4ffd325c26263ac758b7f Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Wed, 4 Feb 2026 23:17:51 +0100 Subject: [PATCH 4/6] Update main.yml --- .github/workflows/main.yml | 76 +++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 703d72c..0af717d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,10 +17,8 @@ jobs: include: - xcode: "16.2" ios: "18.2" - device: "iPhone 16" - xcode: "26.0" ios: "26.0" - device: "iPhone 16" steps: - name: Checkout @@ -37,19 +35,77 @@ jobs: - name: List available simulators run: xcrun simctl list devices available - - name: Prepare for Swift Package build - run: | - # Move xcodeproj out of the way so xcodebuild uses Package.swift - mv Atlantis.xcodeproj Atlantis.xcodeproj.bak + - name: List SwiftPM schemes + run: xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -list + + - name: Install xcpretty + run: sudo gem install xcpretty - - name: List schemes - run: xcodebuild -list + - name: Select iOS simulator for ${{ matrix.ios }} + env: + IOS_VERSION: ${{ matrix.ios }} + run: | + set -euo pipefail + RUNTIME_ID=$(xcrun simctl list runtimes -j | python - <<'PY' + import json, os, sys + data = json.load(sys.stdin) + target = os.environ["IOS_VERSION"] + runtimes = [ + r for r in data.get("runtimes", []) + if r.get("platform") == "iOS" + and r.get("isAvailable") + and r.get("version", "").startswith(target) + ] + if not runtimes: + print(f"Missing iOS runtime for {target}", file=sys.stderr) + sys.exit(1) + print(runtimes[0]["identifier"]) + PY + ) + export RUNTIME_ID + DEVICE_ID=$(xcrun simctl list devices -j | python - <<'PY' + import json, os, sys + data = json.load(sys.stdin) + runtime = os.environ["RUNTIME_ID"] + devices = data.get("devices", {}).get(runtime, []) + for device in devices: + if device.get("isAvailable") and "iPhone 16" in device.get("name", ""): + print(device["udid"]) + sys.exit(0) + for device in devices: + if device.get("isAvailable") and "iPhone" in device.get("name", ""): + print(device["udid"]) + sys.exit(0) + print("") + PY + ) + if [ -z "$DEVICE_ID" ]; then + DEVICE_TYPE=$(xcrun simctl list devicetypes -j | python - <<'PY' + import json, sys + data = json.load(sys.stdin) + devicetypes = [d for d in data.get("devicetypes", []) if d.get("name", "").startswith("iPhone")] + for device in devicetypes: + if device.get("name") == "iPhone 16": + print(device["identifier"]) + sys.exit(0) + if devicetypes: + print(devicetypes[0]["identifier"]) + sys.exit(0) + print("", end="") + sys.exit(1) + PY + ) + DEVICE_ID=$(xcrun simctl create "CI-iPhone-${IOS_VERSION}" "$DEVICE_TYPE" "$RUNTIME_ID") + fi + echo "SIMULATOR_ID=$DEVICE_ID" >> "$GITHUB_ENV" - name: Build and Test on iOS ${{ matrix.ios }} run: | + set -o pipefail xcodebuild test \ + -workspace .swiftpm/xcode/package.xcworkspace \ -scheme Atlantis \ - -destination "platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.ios }}" \ + -destination "id=${SIMULATOR_ID}" \ -skipPackagePluginValidation \ -skipMacroValidation \ - | xcpretty --color || exit ${PIPESTATUS[0]} \ No newline at end of file + | xcpretty --color \ No newline at end of file From 31664b9cdd83cab5b665a7ce48d588c82b9bb9aa Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 08:16:26 +0100 Subject: [PATCH 5/6] Update main.yml --- .github/workflows/main.yml | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0af717d..5e5fea6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,9 +46,16 @@ jobs: IOS_VERSION: ${{ matrix.ios }} run: | set -euo pipefail - RUNTIME_ID=$(xcrun simctl list runtimes -j | python - <<'PY' + RUNTIME_JSON=$(xcrun simctl list runtimes --json 2>/dev/null || true) + if [ -z "$RUNTIME_JSON" ]; then + echo "Failed to read simctl runtimes JSON" + xcrun simctl list runtimes || true + exit 1 + fi + export RUNTIME_JSON + RUNTIME_ID=$(python3 - <<'PY' import json, os, sys - data = json.load(sys.stdin) + data = json.loads(os.environ["RUNTIME_JSON"]) target = os.environ["IOS_VERSION"] runtimes = [ r for r in data.get("runtimes", []) @@ -63,9 +70,16 @@ jobs: PY ) export RUNTIME_ID - DEVICE_ID=$(xcrun simctl list devices -j | python - <<'PY' + DEVICE_JSON=$(xcrun simctl list devices --json 2>/dev/null || true) + if [ -z "$DEVICE_JSON" ]; then + echo "Failed to read simctl devices JSON" + xcrun simctl list devices || true + exit 1 + fi + export DEVICE_JSON + DEVICE_ID=$(python3 - <<'PY' import json, os, sys - data = json.load(sys.stdin) + data = json.loads(os.environ["DEVICE_JSON"]) runtime = os.environ["RUNTIME_ID"] devices = data.get("devices", {}).get(runtime, []) for device in devices: @@ -80,9 +94,16 @@ jobs: PY ) if [ -z "$DEVICE_ID" ]; then - DEVICE_TYPE=$(xcrun simctl list devicetypes -j | python - <<'PY' - import json, sys - data = json.load(sys.stdin) + DEVICE_TYPES_JSON=$(xcrun simctl list devicetypes --json 2>/dev/null || true) + if [ -z "$DEVICE_TYPES_JSON" ]; then + echo "Failed to read simctl device types JSON" + xcrun simctl list devicetypes || true + exit 1 + fi + export DEVICE_TYPES_JSON + DEVICE_TYPE=$(python3 - <<'PY' + import json, sys, os + data = json.loads(os.environ["DEVICE_TYPES_JSON"]) devicetypes = [d for d in data.get("devicetypes", []) if d.get("name", "").startswith("iPhone")] for device in devicetypes: if device.get("name") == "iPhone 16": From ee58f6792581624ca1be7b881dbec13f3b91ec52 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 08:30:05 +0100 Subject: [PATCH 6/6] Update main.yml --- .github/workflows/main.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e5fea6..1afbdd8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,9 +16,7 @@ jobs: matrix: include: - xcode: "16.2" - ios: "18.2" - - xcode: "26.0" - ios: "26.0" + ios: "18" steps: - name: Checkout @@ -61,7 +59,10 @@ jobs: r for r in data.get("runtimes", []) if r.get("platform") == "iOS" and r.get("isAvailable") - and r.get("version", "").startswith(target) + and ( + r.get("version", "") == target + or r.get("version", "").startswith(target + ".") + ) ] if not runtimes: print(f"Missing iOS runtime for {target}", file=sys.stderr)