Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift
T
weishu 3510a0f4f1 fix(ios): Linux compile + full package test round — fixtures green locally (A-CI-local)
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.

Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
  split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
  early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
  InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
  FNV-1a filename fallback, FoundationNetworking imports for URLSession
  types, corelibs URLCache diskPath: initializer, get-only
  waitsForConnectivity, delegate-based SSE transport where
  URLSession.bytes(for:) does not exist
2026-08-18 11:51:57 +08:00

141 lines
4.6 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // URLSession types live here on Linux
#endif
import HapiClient
import HapiProtocol
import Testing
// MARK: - JWT construction
func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// A well-formed (unsigned) JWT with the hub's payload shape.
func makeJWT(uid: Int = 1, ns: String = "default", exp: Int? = nil) -> String {
var fields = ["\"uid\":\(uid)", "\"ns\":\"\(ns)\""]
if let exp {
fields.append("\"exp\":\(exp)")
}
let payload = "{\(fields.joined(separator: ","))}"
return "eyJhbGciOiJIUzI1NiJ9.\(base64URLEncode(Data(payload.utf8))).c2ln"
}
// MARK: - Error capture
/// Runs `body` and returns the error it threw, or `nil` when it succeeded.
func capturedError<T>(_ body: () async throws -> T) async -> (any Error)? {
do {
_ = try await body()
return nil
} catch {
return error
}
}
// MARK: - Scripted HTTP performer
/// Records every request and answers from a FIFO of stubbed responses
/// (falling back to a configurable default when the queue is empty).
actor RecordingPerformer: HTTPPerforming {
struct Stub: Sendable {
var status: Int = 200
var body: Data = Data("{}".utf8)
var headers: [String: String] = [:]
}
private(set) var requests: [URLRequest] = []
private var queue: [Stub] = []
private var fallback = Stub()
private var delayNanoseconds: UInt64 = 0
private var transportError: (any Error & Sendable)?
func enqueue(status: Int = 200, json: String = "{}", headers: [String: String] = [:]) {
queue.append(Stub(status: status, body: Data(json.utf8), headers: headers))
}
/// Makes every subsequent request fail at the transport level (URLError),
/// as `HTTPPerforming` implementations do for unreachable hosts.
func setError(_ error: any Error & Sendable) {
transportError = error
}
func setFallback(status: Int, json: String) {
fallback = Stub(status: status, body: Data(json.utf8))
}
/// Delay before answering — lets concurrent callers pile up on one
/// in-flight exchange.
func setDelay(nanoseconds: UInt64) {
delayNanoseconds = nanoseconds
}
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(request)
if let transportError {
throw transportError
}
if delayNanoseconds > 0 {
try await Task.sleep(nanoseconds: delayNanoseconds)
}
let stub = queue.isEmpty ? fallback : queue.removeFirst()
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: stub.status,
httpVersion: "HTTP/1.1",
headerFields: stub.headers
) else {
throw URLError(.badURL)
}
return (stub.body, response)
}
}
// MARK: - Wiring
let testHubURLString = "https://hub.test"
let testEpochSeconds = 1_700_000_000
let testNow = Date(timeIntervalSince1970: TimeInterval(testEpochSeconds))
struct Harness {
let baseURL: URL
let performer: RecordingPerformer
let store: InMemoryCredentialStore
let auth: AuthManager
let client: APIClient
}
/// Builds the full client stack against a `RecordingPerformer`.
/// `jwt` (when given) is persisted in the credential record, which is how
/// `AuthManager` picks up an initial token.
func makeHarness(
paired: Bool = true,
accessToken: String = "access-token",
jwt: String? = nil,
now: Date = testNow
) throws -> Harness {
let baseURL = try #require(URL(string: testHubURLString))
let performer = RecordingPerformer()
let store = InMemoryCredentialStore()
if paired {
try store.store(HubCredentials(hubUrl: testHubURLString, accessToken: accessToken, jwt: jwt))
}
let auth = AuthManager(baseURL: baseURL, credentialStore: store, performer: performer, now: { now })
let client = APIClient(baseURL: baseURL, authManager: auth, performer: performer)
return Harness(baseURL: baseURL, performer: performer, store: store, auth: auth, client: client)
}
/// A JWT that will not expire during a test.
func freshJWT(ns: String = "default") -> String {
makeJWT(ns: ns, exp: testEpochSeconds + 4 * 3600)
}
func authResponseJSON(token: String) -> String {
"{\"token\":\"\(token)\",\"user\":{\"id\":1}}"
}