test(ios): real Swift compile + XCTest fixture; device-path probe; loopback bind fix

Closes the gap from prior commits where E2E tests stubbed the Swift StateServer
in TypeScript. Now there's a real SwiftPM fixture at test/fixtures/ios-qa/FixtureApp/
that compiles the production templates and runs an XCTest suite against the
actual StateServer implementation. Three new test layers:

- swift build invariants (periodic-tier): debug-config build succeeds, XCTest
  suite passes (validates real Swift impl over Foundation + Network), release-config
  build has zero DebugBridge symbols (structural #if DEBUG gate works end-to-end).

- Real-device probe (periodic-tier, GSTACK_HAS_IOS_DEVICE=1): devicectl can list
  + pair the connected iPhone. Surfaces actionable instructions when the trust
  dialog hasn't been confirmed yet.

- Fixture sources copied from ios-qa/templates/ — Package.swift splits the
  bridge into DebugBridgeCore (Foundation+Network, cross-platform) and
  DebugBridgeUI (UIKit/SwiftUI, iOS-only) so swift build can validate the
  bulk of the production code on macOS without an iPhone or simulator.

Also fixes a real bug the XCTest unit suite caught: NWListener with
requiredLocalEndpoint on params silently fails to bind for listening (it's
an outbound-connection concept). Replaced with .requiredInterfaceType=.loopback
+ .acceptLocalOnly=true + a per-connection peer-address check. The fork's
inherited code had this bug; we shipped it untouched in v1.41.0.0 and the
new XCTest suite caught it immediately.
This commit is contained in:
Garry Tan
2026-05-17 20:12:03 -07:00
parent d184edef78
commit b4fe510648
11 changed files with 1246 additions and 2 deletions
@@ -0,0 +1,107 @@
// XCTest unit test for StateServer. Runs the real Swift implementation on
// macOS (#if DEBUG, loopback bind, full Foundation+Network stack) and
// exercises the auth flow + session lock + snapshot endpoints over HTTP.
//
// This is what validates that the production Swift code actually works,
// not just that it compiles. Daemon integration tests already cover the
// TS side; this covers the Swift side without an iPhone.
import XCTest
import Foundation
@testable import DebugBridgeCore
#if DEBUG
@MainActor
final class StateServerSmokeTests: XCTestCase {
/// Build URL for a loopback call. Use IPv6 since CoreDevice tunnels are IPv6,
/// and the StateServer template uses IPv6 first.
func loopbackURL(port: UInt16, path: String) -> URL {
URL(string: "http://[::1]:\(port)\(path)")!
}
/// Issue an HTTP request and decode JSON. Returns (status, body).
func request(method: String, url: URL, headers: [String: String] = [:], body: Data? = nil) async throws -> (Int, [String: Any]) {
var req = URLRequest(url: url)
req.httpMethod = method
for (k, v) in headers { req.setValue(v, forHTTPHeaderField: k) }
if let body = body { req.httpBody = body }
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
return (status, json)
}
/// Spin up StateServer on a random port, wait briefly for binding to settle.
/// Returns the port. Uses StateServer.shared since it's a singleton.
func spinUp() async throws -> UInt16 {
// Port 0 doesn't work with NWListener directly; pick a high random.
let port: UInt16 = UInt16.random(in: 30000...39999)
StateServer.shared.start() // starts on default 9999, but template uses fixed
// The template hardcodes port 9999 we test against that.
// Sleep briefly for binding to complete.
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
return 9999
}
func test_healthz_returns_200_without_auth() async throws {
let port = try await spinUp()
let (status, body) = try await request(method: "GET", url: loopbackURL(port: port, path: "/healthz"))
XCTAssertEqual(status, 200, "healthz should return 200 without auth on loopback")
XCTAssertEqual(body["version"] as? String, "1.0.0")
}
func test_tap_requires_auth() async throws {
let port = try await spinUp()
let (status, _) = try await request(method: "POST", url: loopbackURL(port: port, path: "/tap"))
XCTAssertEqual(status, 401, "mutating endpoint without bearer must return 401")
}
/// Boot token rotation is the load-bearing security property. Confirm:
/// 1. Boot token is required for /auth/rotate
/// 2. After rotation, boot token is dead
/// 3. Rotated token works for subsequent calls
func test_boot_token_rotation_kills_original() async throws {
let port = try await spinUp()
// Read boot token from os_log scrape in production this comes from
// devicectl process launch. For this test we can read it from the
// bootTokenPath file. (StateServer writes a 0600 file as fallback.)
let bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token"
let bootToken = try? String(contentsOfFile: bootTokenPath, encoding: .utf8)
guard let bt = bootToken?.trimmingCharacters(in: .whitespacesAndNewlines), !bt.isEmpty else {
throw XCTSkip("Boot token file not written — StateServer may not have started cleanly")
}
// Rotate.
let newToken = "rotated-test-token-\(UUID().uuidString)"
let rotateBody = try JSONSerialization.data(withJSONObject: ["new_token": newToken])
let (rotateStatus, _) = try await request(
method: "POST",
url: loopbackURL(port: port, path: "/auth/rotate"),
headers: ["Authorization": "Bearer \(bt)", "Content-Type": "application/json"],
body: rotateBody
)
XCTAssertEqual(rotateStatus, 200, "rotate with valid boot token should succeed")
// Original boot token should now be dead.
let (deadStatus, _) = try await request(
method: "POST",
url: loopbackURL(port: port, path: "/auth/rotate"),
headers: ["Authorization": "Bearer \(bt)", "Content-Type": "application/json"],
body: rotateBody
)
XCTAssertEqual(deadStatus, 401, "boot token must be dead after rotation")
// New token works.
let (acqStatus, _) = try await request(
method: "POST",
url: loopbackURL(port: port, path: "/session/acquire"),
headers: ["Authorization": "Bearer \(newToken)"]
)
XCTAssertEqual(acqStatus, 200, "rotated token must work for session acquire")
}
}
#endif // DEBUG