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
101 lines
4.8 KiB
Swift
101 lines
4.8 KiB
Swift
//
|
||
// MirrorLifecycleTests.swift — T-iOS-16 测试 4/6/7:JOIN mirror 与会话生命周期
|
||
//
|
||
// 4. 双客户端 JOIN mirror:A、B 同 attach 一个会话(新 attach 加入镜像、绝不
|
||
// 踢人,src/session/manager.ts:108-174),A input,B 收到 output。
|
||
// 6. DELETE /live-sessions/:id(kill)→ 镜像客户端观察到 WS close —— 不是
|
||
// exit 帧:manager.killById 先 ws.close() 全部客户端再 kill PTY
|
||
// (src/session/manager.ts:202-212),exit 广播只投给 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)
|
||
}
|
||
}
|