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,100 @@
//
// MirrorLifecycleTests.swift T-iOS-16 4/6/7JOIN mirror
//
// 4. JOIN mirrorAB attach attach
// src/session/manager.ts:108-174A inputB output
// 6. DELETE /live-sessions/:idkill WS close
// exit manager.killById ws.close() kill PTY
// src/session/manager.ts:202-212exit 广 OPEN socket
// sendIfOpen id GET /live-sessions
// 7. 退广A input "exit\r" bash 退 pty.onExit
// socket 广 {type:'exit'}src/session/session.ts:146-151 B
import Foundation
import Testing
import WireProtocol
@Suite("T-iOS-16 镜像与生命周期(真服务器)", .serialized, .timeLimit(.minutes(5)))
struct MirrorLifecycleTests {
@Test("双客户端 JOIN mirror:B attach 同一会话拿到同 id,A input → B 收 output")
func mirrorJoinSharesOutput() async throws {
// Arrange: A ,B id (, A)
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
let adoptedByB = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
#expect(adoptedByB == sessionId, "镜像 attach 应采用同一会话 id(JOIN 不踢)")
// Act: A $((1300+37)) shell 1337
var readerB = TranscriptReader(client: clientB)
try await clientA.send(shellCommand("echo \"MIR:$((1300+37))\""))
// Assert: B() A 广
try await readerB.awaitContains("MIR:1337")
// Cleanup
clientA.close()
clientB.close()
await killSessionBestEffort(server: server, id: sessionId)
}
@Test("DELETE kill → 镜像观察到 WS close(非 exit 帧),id 从 /live-sessions 消失")
func killClosesMirrorSocketsWithoutExitFrame() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
let idString = sessionId.uuidString.lowercased()
// Act: Origin kill(G ,src/server.ts:354-359)
let status = try await deleteLiveSession(server: server, id: idString, origin: server.origin)
// Assert: 204;id (killById ); close
// exit (close-first ,src/session/manager.ts:202-212)
#expect(status == 204, "kill 应 204,实际 \(status)")
let idsAfter = try await liveSessionIds(server: server)
#expect(!idsAfter.contains(idString), "kill 后 id 不应再出现在 GET /live-sessions")
let observedB = await drainUntilClose(client: clientB)
#expect(observedB.closed, "镜像客户端 B 应观察到 WS close")
#expect(!observedB.sawExit, "kill 路径不广播 exit 帧(先 close 后 kill)")
}
@Test("自然退出广播:A input exit → 镜像 B 收到 {type:'exit'} 帧")
func naturalExitBroadcastsExitFrameToMirror() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
// Act: A shell 退
try await clientA.send(shellCommand("exit"))
// Assert: B exit (pty.onExit 广,src/session/session.ts:146-151)
let exit = try await awaitExitFrame(client: clientB)
#expect(exit.code == 0, "bash `exit` 应干净退出,实际 code=\(exit.code)")
// Cleanup(退;kill )
clientA.close()
clientB.close()
await killSessionBestEffort(server: server, id: sessionId)
}
}