Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

336 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-8 · ****plan §4
/// - `LiveSessionInfo` src/types.ts:246-256`telemetry` / null
/// - `status` `.unknown`
/// - crash body "?"
/// - `TimelineEvent` WireProtocol `decodeList` `class`
struct ModelDecodingTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private static let fullSessionJSON = """
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":2,\
"status":"working","exited":false,"cwd":"/Users/dev/proj","cols":120,"rows":40,\
"telemetry":{"contextUsedPct":42.5,"costUsd":1.25,"linesAdded":10,"linesRemoved":2,\
"model":"Opus","effort":"high",\
"pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\
"rate":{"fiveHourPct":12.5,"sevenDayPct":40.0},"at":1720000005000}}
"""
private static let noTelemetrySessionJSON = """
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":0,\
"status":"idle","exited":false,"cwd":null,"cols":80,"rows":24}
"""
private func makeClient(_ http: FakeHTTPTransport) throws -> APIClient {
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return APIClient(endpoint: endpoint, http: http)
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func fetchSessions(body: String) async throws -> [LiveSessionInfo] {
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data(body.utf8))
return try await client.liveSessions()
}
// MARK: - LiveSessionInfo
@Test("LiveSessionInfo (src/types.ts:246-256 , telemetry)")
func liveSessionInfoDecodesFullSample() async throws {
// Act
let sessions = try await fetchSessions(body: "[\(Self.fullSessionJSON)]")
// Assert
#expect(sessions.count == 1)
let session = try #require(sessions.first)
#expect(session.id == UUID(uuidString: Self.sessionIdString))
#expect(session.createdAt == 1_720_000_000_000)
#expect(session.clientCount == 2)
#expect(session.status == .working)
#expect(session.exited == false)
#expect(session.cwd == "/Users/dev/proj")
#expect(session.cols == 120)
#expect(session.rows == 40)
let telemetry = try #require(session.telemetry)
#expect(telemetry.contextUsedPct == 42.5)
#expect(telemetry.costUsd == 1.25)
#expect(telemetry.linesAdded == 10)
#expect(telemetry.linesRemoved == 2)
#expect(telemetry.model == "Opus")
#expect(telemetry.effort == "high")
#expect(telemetry.pr?.number == 7)
#expect(telemetry.rate?.fiveHourPct == 12.5)
#expect(telemetry.at == 1_720_000_005_000)
// 与 memberwise 构造的期望值整体相等(Equatable 全字段往返)
let expected = LiveSessionInfo(
id: try #require(UUID(uuidString: Self.sessionIdString)),
createdAt: 1_720_000_000_000,
clientCount: 2,
status: .working,
exited: false,
cwd: "/Users/dev/proj",
cols: 120,
rows: 40,
telemetry: StatusTelemetry(
contextUsedPct: 42.5, costUsd: 1.25, linesAdded: 10, linesRemoved: 2,
model: "Opus", effort: "high",
pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"),
rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 40.0),
at: 1_720_000_005_000
)
)
#expect(session == expected)
}
@Test("LiveSessionInfo telemetry 缺失/cwd 为 null → 解码为 nil,其余字段完整")
func liveSessionInfoToleratesMissingTelemetryAndNullCwd() async throws {
// Act
let sessions = try await fetchSessions(body: "[\(Self.noTelemetrySessionJSON)]")
// Assert
let session = try #require(sessions.first)
#expect(session.telemetry == nil)
#expect(session.cwd == nil)
#expect(session.status == .idle)
#expect(session.cols == 80)
#expect(session.rows == 24)
}
@Test("LiveSessionInfo.lastOutputAt 可选字段(T-iOS-37/23):在场→解出;缺席(旧服务器)→nil;坏类型→nil 不丢条目")
func lastOutputAtDecodesAsOptional() async throws {
// Arrange: / /
let present = Self.noTelemetrySessionJSON
.replacingOccurrences(of: #""createdAt":1720000000000"#,
with: #""createdAt":1720000000000,"lastOutputAt":1720000007000"#)
let wrongType = Self.fullSessionJSON
.replacingOccurrences(of: #""createdAt":1720000000000"#,
with: #""createdAt":1720000000000,"lastOutputAt":"soon""#)
// Act
let sessions = try await fetchSessions(
body: "[\(present),\(Self.noTelemetrySessionJSON),\(wrongType)]"
)
// Assert
#expect(sessions.count == 3)
#expect(sessions[0].lastOutputAt == 1_720_000_007_000)
#expect(sessions[1].lastOutputAt == nil)
#expect(sessions[2].lastOutputAt == nil)
}
@Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)")
func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws {
// Arrange
let body = Self.noTelemetrySessionJSON
.replacingOccurrences(of: #""status":"idle""#, with: #""status":"hyperdrive""#)
// Act
let sessions = try await fetchSessions(body: "[\(body)]")
// Assert
#expect(sessions.count == 1)
#expect(sessions.first?.status == .unknown)
}
@Test("畸形条目(非对象/坏 UUID/缺必填)逐条丢弃,合法条目保留,不 crash")
func malformedEntriesAreDroppedWhileValidOnesSurvive() async throws {
// Arrange
let badUUID = Self.noTelemetrySessionJSON
.replacingOccurrences(of: Self.sessionIdString, with: "not-a-uuid")
let body = "[\(Self.fullSessionJSON),42,\(badUUID),{\"id\":\"x\"}]"
// Act
let sessions = try await fetchSessions(body: body)
// Assert
#expect(sessions.count == 1)
#expect(sessions.first?.id == UUID(uuidString: Self.sessionIdString))
}
@Test("非数组 body(HTML/对象)→ invalidResponseBody 显式错误(探针的 端口对吗 信号)")
func nonArrayBodyThrowsInvalidResponseBody() async throws {
// Act + Assert HTML()
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await self.fetchSessions(body: "<html><body>router admin</body></html>")
}
// Act + Assert JSON
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await self.fetchSessions(body: #"{"error":"nope"}"#)
}
}
// MARK: - TimelineEvent WireProtocol helper
@Test("TimelineEvent 未知 class 值 → 该条丢弃不 crash(服务器视为不可信)")
func unknownTimelineClassEntriesAreDroppedNotCrashed() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
let body = """
[{"at":1,"class":"tool","toolName":"Bash","label":"ran Bash"},\
{"at":2,"class":"quantum","label":"??"},\
{"at":3,"class":"waiting","label":"waiting for approval"}]
"""
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
body: Data(body.utf8)
)
// Act
let events = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
#expect(events.count == 2)
#expect(events.map(\.class) == ["tool", "waiting"])
#expect(events.first?.toolName == "Bash")
}
@Test("events 404 → sessionNotFound")
func events404ThrowsSessionNotFound() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
status: 404
)
// Act + Assert
await #expect(throws: APIClientError.sessionNotFound) {
_ = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
// MARK: - SessionPreview / UiConfig
@Test("SessionPreview 全字段解码(GET /live-sessions/:id/preview 形状)")
func sessionPreviewDecodesAllFields() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
let body = #"{"id":"\#(Self.sessionIdString)","cols":100,"rows":30,"data":"hello"}"#
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
body: Data(body.utf8)
)
// Act
let preview = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
let expected = SessionPreview(
id: try #require(UUID(uuidString: Self.sessionIdString)),
cols: 100, rows: 30, data: "hello"
)
#expect(preview == expected)
}
@Test("preview 200 但 body 非预期形状 → invalidResponseBody")
func previewGarbageBodyThrowsInvalidResponseBody() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
body: Data("not json".utf8)
)
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
@Test("preview 404 → sessionNotFound")
func preview404ThrowsSessionNotFound() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
status: 404
)
// Act + Assert
await #expect(throws: APIClientError.sessionNotFound) {
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
@Test("UiConfig 解码 allowAutoMode(GET /config/ui,src/types.ts:503)")
func uiConfigDecodesAllowAutoMode() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/config/ui"),
body: Data(#"{"allowAutoMode":true}"#.utf8)
)
// Act
let config = try await client.uiConfig()
// Assert
#expect(config == UiConfig(allowAutoMode: true))
}
@Test("uiConfig 200 但 body 非预期形状 → invalidResponseBody")
func uiConfigGarbageBodyThrowsInvalidResponseBody() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/config/ui"), body: Data("[]".utf8))
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await client.uiConfig()
}
}
@Test("liveSessions 非 200(500) → unexpectedStatus(500)")
func liveSessions500ThrowsUnexpectedStatus() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/live-sessions"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await client.liveSessions()
}
}
// MARK: -
@Test("decisionRejected 话术明示 token 已过期/已被处理(RED 清单:403 → token 过期话术)")
func decisionRejectedMessageMentionsTokenExpiry() {
#expect(APIClientError.decisionRejected.message.contains("过期"))
}
@Test("APIClientError 每个 case 都有非空 UI 话术(plan §4:错误处理全面显式)")
func everyAPIClientErrorCaseHasNonEmptyMessage() {
// Arrange
let allCases: [APIClientError] = [
.invalidRequest, .invalidResponseBody, .sessionNotFound,
.forbidden, .decisionRejected, .rateLimited, .unexpectedStatus(500),
]
// Act + Assert
for errorCase in allCases {
#expect(!errorCase.message.isEmpty)
}
#expect(APIClientError.unexpectedStatus(500).message.contains("500"))
}
}