feat(ios): W3 UI layer + integration CI + ntfy docs

T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -0,0 +1,433 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
/// domain these tests cover the state mapping around it:
/// scan/manual input confirm gate (ZERO network before the user says go)
/// probe Host into the store + navigate signal, error taxonomy copy +
/// action, and the §5.4 warning tiers.
///
/// Determinism: the probe is injected as a closure; scripted results come from
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
/// `FakeTransport` zero real network, zero real waits.
@MainActor
@Suite("PairingViewModel")
struct PairingViewModelTests {
// MARK: - Probe script (scripted results + invocation recording)
private actor ProbeScript {
private(set) var calls: [HostEndpoint] = []
private var results: [Result<HostEndpoint, PairingError>]
/// Empty `results` = always succeed with the probed endpoint.
init(results: [Result<HostEndpoint, PairingError>] = []) {
self.results = results
}
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
calls = calls + [endpoint]
guard let next = results.first else { return .success(endpoint) }
results = Array(results.dropFirst())
return next
}
}
private func makeViewModel(
store: any HostStore = InMemoryHostStore(),
script: ProbeScript
) -> PairingViewModel {
PairingViewModel(store: store, probe: { await script.invoke($0) })
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { [] }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
// MARK: - Scan confirm gate REAL two-step probe store + navigate
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
func scanConfirmGateThenRealProbePairsHost() async throws {
// Arrange: REAL runPairingProbe over fakes the strongest proof that
// (a) nothing touches the wire before the user confirms and (b) the
// production closure wiring is exercised end to end.
let http = FakeHTTPTransport()
let ws = FakeTransport()
let store = InMemoryHostStore()
let viewModel = PairingViewModel(
store: store,
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
)
let base = "http://192.168.1.5:3000"
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
// Script the full happy path UP FRONT if the VM probed before
// confirm, recordedRequests would already be non-empty below.
await http.queueSuccess(
url: try #require(URL(string: "\(base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
status: 204
)
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
viewModel.handleScannedCode(base)
// Assert: confirm state shows the single-point-derived address and the
// scanned host has seen ZERO network traffic (probe would GET, probe
// would spawn a PTY on the target untrusted scan input, plan §5).
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.displayAddress == base)
#expect(pending.endpoint.originHeader == base)
#expect(pending.warning == .plaintextLAN)
#expect(await http.recordedRequests.isEmpty)
#expect(await ws.connectAttempts.isEmpty)
#expect(viewModel.pairedHost == nil)
// Act: name the host, then confirm ONLY now may the probe run.
viewModel.hostName = "书房 Mac"
await viewModel.confirmConnect()
// Assert: paired + navigate signal; Host{id,name} constructed by the
// VM (§3.4 contract ruling) and upserted into the store.
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.name == "书房 Mac")
#expect(host.endpoint == pending.endpoint)
#expect(viewModel.pairedHost == host)
#expect(try await store.loadAll() == [host])
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
// iff guarded (§3.4 ) and one WS attach round-trip, closed.
let requests = await http.recordedRequests
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
#expect(await ws.connectAttempts.count == 1)
#expect(await ws.closeCallCount == 1)
// Assert: a stray scan cannot preempt a finished pairing.
viewModel.handleScannedCode("http://10.0.0.9:3000")
#expect(viewModel.phase == .paired(host))
}
// MARK: - Input boundary: scan payloads are untrusted external input
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
"ftp://192.168.1.5:3000",
"ws://192.168.1.5:3000",
"javascript:alert(1)",
"WIFI:S:mynet;T:WPA;P:hunter2;;",
"",
])
func scanRejectsNonHTTPPayloads(payload: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.handleScannedCode(payload)
// Assert: stays idle, inline rejection copy, probe never invoked.
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - Manual entry (documented decision: reuses the confirm state)
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
("192.168.1.5:3000", "http://192.168.1.5:3000"),
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
])
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
// surface documented T-iOS-12 decision), still zero probe calls.
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
return
}
#expect(pending.endpoint.originHeader == expectedOrigin)
#expect(await script.calls.isEmpty)
}
@Test("unparseable manual input is rejected with copy", arguments: [
"", " ", "://nope", "http://",
])
func manualEntryRejectsUnparseableInput(input: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - §5.4 warning tiers (shown on the confirm page)
@Test("warning tiers follow the §5.4 table", arguments: [
// public host strongest BLOCKING warning, http AND https alike
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
// ws:// to RFC1918 / link-local / .local non-blocking plaintext notice
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) no plaintext
// warning (WireGuard already encrypts); positive badge instead
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
// loopback none; https to a private-class host none
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
])
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
// Arrange
let baseURL = try #require(URL(string: url))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
// Act & Assert
#expect(PairingViewModel.warning(for: endpoint) == expected)
}
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
func blockingWarningGatesTheProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://203.0.113.7:3000")
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.warning == .publicHostBlocking)
// Act: confirm WITHOUT acknowledging the risk.
await viewModel.confirmConnect()
// Assert: no probe, still confirming, the UI is told to demand the ack.
#expect(await script.calls.isEmpty)
#expect(viewModel.phase == .confirming(pending))
#expect(viewModel.needsPublicRiskAcknowledgement)
// Act: explicit acknowledgement, then confirm again.
viewModel.hasAcknowledgedPublicRisk = true
await viewModel.confirmConnect()
// Assert: probe ran exactly once and pairing completed.
#expect(await script.calls.count == 1)
guard case .paired = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
}
// MARK: - PairingError taxonomy inline copy + recovery action
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
(PairingError.localNetworkDenied,
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
["本地网络", "设置"]),
(PairingError.hostUnreachable(underlying: "Connection refused"),
PairingViewModel.RecoveryAction.retry,
["Connection refused"]),
(PairingError.httpOkButNotWebTerminal,
PairingViewModel.RecoveryAction.retry,
["端口"]),
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
PairingViewModel.RecoveryAction.retry,
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
(PairingError.atsBlocked(host: "198.18.0.1"),
PairingViewModel.RecoveryAction.retry,
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
(PairingError.tlsFailure,
PairingViewModel.RecoveryAction.retry,
["TLS"]),
(PairingError.timeout,
PairingViewModel.RecoveryAction.retry,
["超时"]),
])
func pairingErrorMapsToCopyAndAction(
error: PairingError,
expectedAction: PairingViewModel.RecoveryAction,
requiredFragments: [String]
) async throws {
// Arrange: private-class host so no blocking-warning gate interferes.
let script = ProbeScript(results: [.failure(error)])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
return
}
#expect(failure.action == expectedAction)
#expect(!failure.message.isEmpty)
for fragment in requiredFragments {
#expect(failure.message.contains(fragment),
"copy for \(error) must contain \(fragment)")
}
}
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
func originRejectedHintIsVerbatim() async throws {
// Arrange: the hint the probe derives from endpoint.originHeader is
// already the complete actionable copy never rewrap or re-derive it.
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == hint)
}
// MARK: - Retry / cancel
@Test("retry re-runs the probe against the same endpoint and can succeed")
func retryRerunsProbeAfterFailure() async throws {
// Arrange: first probe times out, second succeeds.
let script = ProbeScript(results: [.failure(.timeout)])
let store = InMemoryHostStore()
let viewModel = makeViewModel(store: store, script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
await viewModel.confirmConnect()
guard case .failed = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
// Act
await viewModel.retry()
// Assert: two probe calls, same endpoint, pairing completed.
let calls = await script.calls
#expect(calls.count == 2)
#expect(calls.first == calls.last)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(try await store.loadAll() == [host])
}
@Test("cancel returns to idle without ever probing")
func cancelReturnsToIdleWithoutProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
viewModel.cancel()
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == nil)
#expect(await script.calls.isEmpty)
}
// MARK: - Store failure is explicit, never silent
@Test("a store failure after a successful probe surfaces an explicit retryable error")
func storeFailureSurfacesExplicitError() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert: failed with the dedicated copy; no navigate signal.
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == PairingCopy.storeFailed)
#expect(failure.action == .retry)
#expect(viewModel.pairedHost == nil)
}
// MARK: - Host naming
@Test("host name defaults to the endpoint host and user names are trimmed")
func hostNameDefaultsAndTrims() async throws {
// Arrange & Act: untouched name default = endpoint host.
let script = ProbeScript()
let storeA = InMemoryHostStore()
let viewModelA = makeViewModel(store: storeA, script: script)
viewModelA.handleScannedCode("http://192.168.1.5:3000")
#expect(viewModelA.hostName == "192.168.1.5")
await viewModelA.confirmConnect()
// Assert
guard case .paired(let defaultNamed) = viewModelA.phase else {
Issue.record("expected .paired, got \(viewModelA.phase)")
return
}
#expect(defaultNamed.name == "192.168.1.5")
// Arrange & Act: user-typed name is trimmed before storing.
let storeB = InMemoryHostStore()
let viewModelB = makeViewModel(store: storeB, script: script)
viewModelB.handleScannedCode("http://192.168.1.5:3000")
viewModelB.hostName = " 书房 Mac "
await viewModelB.confirmConnect()
// Assert
guard case .paired(let userNamed) = viewModelB.phase else {
Issue.record("expected .paired, got \(viewModelB.phase)")
return
}
#expect(userNamed.name == "书房 Mac")
}
}