diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 78bc1fc..1afbdd8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,12 +9,125 @@ 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" 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: List SwiftPM schemes + run: xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -list + + - name: Install xcpretty + run: sudo gem install xcpretty + + - name: Select iOS simulator for ${{ matrix.ios }} + env: + IOS_VERSION: ${{ matrix.ios }} + run: | + set -euo pipefail + 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.loads(os.environ["RUNTIME_JSON"]) + 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", "") == target + or 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_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.loads(os.environ["DEVICE_JSON"]) + 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_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": + 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 "id=${SIMULATOR_ID}" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + | xcpretty --color \ No newline at end of file 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) + } }