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)
+ }
}