// // 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) } }