import Foundation import HostRegistry import SessionCore import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理。 /// /// 1. "在当前目录开新会话"(TerminalScreen 工具栏 / exit 横幅共用动作)→ /// `AppCoordinator.openNewSessionInCurrentCwd()`:单活 WS 不变式下的 /// close→open,新会话首帧 `attach(null, cwd)`(镜像 web tabs.ts /// `newTab()` M6——取活跃会话 cwd)。cwd 解析次序:/live-sessions 行数据 /// (server-adopted id 匹配行)→ controller 的 spawnCwd(T-iOS-26 项目 /// fresh-spawn 尚未进列表轮询)→ 未知(普通新会话)。服务器数据不可信: /// 非绝对路径按未知处理(ProjectsViewModel 同款纪律)。 /// 2. exited 会话点开 → 回放渲染 + exit 横幅(TerminalViewModel P0 已有)+ /// 横幅"开新会话"动作(src/session/manager.ts:145-153:exited 会话在 /// reap 前保持可列出,attach 即回放 ring buffer + 补发 exit 帧)。 /// /// 真 `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport, /// 零真实等待(openTask + waitUntilProcessed 双屏障,ProjectOpenWiringTests /// 同款)。 @MainActor @Suite("NewSessionInCwd (T-iOS-29)") struct NewSessionInCwdTests { private nonisolated static let base = "http://192.168.1.5:3000" // MARK: - Fixtures(SessionSwitcherTests 的 WiringFixture 同款) private struct Fixture { let transport: FakeTransport let http: FakeHTTPTransport let host: HostRegistry.Host let unreadStore: InMemoryUnreadWatermarkStore let environment: AppEnvironment } private func makeFixture(suiteName: String) throws -> Fixture { let baseURL = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) let transport = FakeTransport() let http = FakeHTTPTransport() let unreadStore = InMemoryUnreadWatermarkStore() let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) let defaults = try #require(UserDefaults(suiteName: suiteName)) return Fixture( transport: transport, http: http, host: host, unreadStore: unreadStore, environment: AppEnvironment( hostStore: InMemoryHostStore(hosts: [host]), lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults), http: http, termTransport: transport, probe: { _ in .failure(.timeout) }, unreadStore: unreadStore ) ) } private func listURL() throws -> URL { try #require(URL(string: "\(Self.base)/live-sessions")) } private func sessionJSON(id: UUID, cwd: String?, exited: Bool = false) -> String { let cwdJSON = cwd.map { ",\"cwd\":\"\($0)\"" } ?? "" return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000" + ",\"clientCount\":0,\"status\":\"idle\",\"exited\":\(exited)" + "\(cwdJSON),\"cols\":80,\"rows\":24}" } private func listBody(_ entries: [String]) -> Data { Data("[\(entries.joined(separator: ","))]".utf8) } /// 让列表 VM 先掌握行数据(cwd 的唯一服务器来源)。 private func primeRows(_ coordinator: AppCoordinator, fixture: Fixture, rows: [String]) async throws { await coordinator.sessionList.reloadHosts() await fixture.http.queueSuccess(url: try listURL(), body: listBody(rows)) await coordinator.sessionList.refresh() } /// attach 握手完成(服务器 adopted):openTask 完成 ∧ 前 3 个事件 /// (connecting/connected/adopted)已应用。 private func adopt( _ controller: TerminalSessionController, transport: FakeTransport, sessionId: UUID ) async { await controller.openTask?.value await transport.emit( frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"# ) await controller.terminalViewModel.waitUntilProcessed(eventCount: 3) } /// 新 controller 的 open 已提交且连接事件已冲刷(帧必已落地)。 private func awaitAttachSettled(_ controller: TerminalSessionController) async { await controller.openTask?.value await controller.terminalViewModel.waitUntilProcessed(eventCount: 2) } // MARK: - 1. 在当前会话 cwd 开新会话 @Test("cwd 来自 /live-sessions 行 → 切换后新连接首帧 attach(null, cwd);旧会话记 last-seen") func newInCwdUsesListRowCwd() async throws { // Arrange:列表已知该会话 cwd,打开并 adopted。 let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.rowCwd") let coordinator = AppCoordinator(environment: fixture.environment) let sessionId = UUID() try await primeRows( coordinator, fixture: fixture, rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj")] ) coordinator.open(SessionListViewModel.OpenRequest( id: UUID(), host: fixture.host, sessionId: sessionId )) let first = try #require(coordinator.terminalController) await adopt(first, transport: fixture.transport, sessionId: sessionId) // Act:工具栏/横幅动作。 coordinator.openNewSessionInCurrentCwd() // Assert:close→open —— 新 controller、新连接,首帧带行 cwd 的 fresh spawn。 let second = try #require(coordinator.terminalController) #expect(second !== first) await awaitAttachSettled(second) let byConnection = await fixture.transport.sentFramesByConnection #expect(byConnection.count == 2) #expect(byConnection[1] == [ MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")) ]) // 旧会话经 closeTerminal 记 last-seen(切走即已读)。 #expect(fixture.unreadStore.snapshot[sessionId] != nil) second.teardown() } @Test("行缺席(fresh spawn 未入轮询)→ 回退 controller.spawnCwd;bootstrap 绝不复注入") func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws { // Arrange:项目内 fresh spawn(spawnCwd + claude bootstrap),列表无行。 let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.spawnCwd") let coordinator = AppCoordinator(environment: fixture.environment) coordinator.openProject(ProjectOpenRequest( id: UUID(), host: fixture.host, cwd: "/repos/api", bootstrapInput: ProjectLaunch.claudeBootstrapInput )) let first = try #require(coordinator.terminalController) await adopt(first, transport: fixture.transport, sessionId: UUID()) // Act coordinator.openNewSessionInCurrentCwd() // Assert:同 cwd,但只有裸 attach —— claude\r 不属于"开新 shell"。 let second = try #require(coordinator.terminalController) await awaitAttachSettled(second) let byConnection = await fixture.transport.sentFramesByConnection #expect(byConnection.count == 2) #expect(byConnection[1] == [ MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")) ]) second.teardown() } @Test("cwd 全未知(无行、无 spawnCwd)→ 普通新会话 attach(null, nil)") func newInCwdWithUnknownCwdOpensPlainSession() async throws { // Arrange:直接"+ 新会话"打开(无列表行),adopted id 不在任何行里。 let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.unknown") let coordinator = AppCoordinator(environment: fixture.environment) coordinator.open(SessionListViewModel.OpenRequest( id: UUID(), host: fixture.host, sessionId: nil )) let first = try #require(coordinator.terminalController) await adopt(first, transport: fixture.transport, sessionId: UUID()) // Act coordinator.openNewSessionInCurrentCwd() // Assert let second = try #require(coordinator.terminalController) await awaitAttachSettled(second) let byConnection = await fixture.transport.sentFramesByConnection #expect(byConnection.count == 2) #expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) second.teardown() } @Test("服务器行 cwd 非绝对路径(不可信输入)→ 按未知处理,attach(null, nil)") func hostileRelativeCwdIsTreatedAsUnknown() async throws { // Arrange:敌意/损坏的服务器数据 —— cwd 不是绝对路径。 let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.hostile") let coordinator = AppCoordinator(environment: fixture.environment) let sessionId = UUID() try await primeRows( coordinator, fixture: fixture, rows: [sessionJSON(id: sessionId, cwd: "repos/../etc")] ) coordinator.open(SessionListViewModel.OpenRequest( id: UUID(), host: fixture.host, sessionId: sessionId )) let first = try #require(coordinator.terminalController) await adopt(first, transport: fixture.transport, sessionId: sessionId) // Act coordinator.openNewSessionInCurrentCwd() // Assert:非法 cwd 绝不透传。 let second = try #require(coordinator.terminalController) await awaitAttachSettled(second) let byConnection = await fixture.transport.sentFramesByConnection #expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) second.teardown() } @Test("无打开的终端 → 动作 no-op(不 spawn、不连接)") func actionWithoutOpenTerminalIsNoOp() async throws { // Arrange let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.noop") let coordinator = AppCoordinator(environment: fixture.environment) // Act coordinator.openNewSessionInCurrentCwd() // Assert #expect(coordinator.terminalController == nil) let attempts = await fixture.transport.connectAttempts #expect(attempts.isEmpty) } // MARK: - 2. exited 会话:回放 + exit 横幅 + 横幅"开新会话" @Test("exited 会话点开:回放渲染 + exit 横幅只读 → 动作以该行 cwd 开新会话") func exitedSessionReplaysThenBannerActionReusesCwd() async throws { // Arrange:列表里的 exited 行(manager.ts:145-153——reap 前仍可列出)。 let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.exited") let coordinator = AppCoordinator(environment: fixture.environment) let sessionId = UUID() try await primeRows( coordinator, fixture: fixture, rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj", exited: true)] ) coordinator.open(SessionListViewModel.OpenRequest( id: UUID(), host: fixture.host, sessionId: sessionId )) let first = try #require(coordinator.terminalController) var fed: [String] = [] first.terminalViewModel.attachTerminalSink { fed.append($0) } await first.openTask?.value // Act:服务器语义 —— 回放 ring buffer,随后补发 exit。 await fixture.transport.emit( frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"# ) await fixture.transport.emit(frame: #"{"type":"output","data":"[replay] $ make done"}"#) await fixture.transport.emit(frame: #"{"type":"exit","code":0}"#) await first.terminalViewModel.waitUntilProcessed(eventCount: 5) // Assert:回放已渲染、exit 横幅只读。 #expect(fed == ["[replay] $ make done"]) #expect(first.terminalViewModel.bannerModel == .exited(code: 0, reason: nil)) #expect(first.terminalViewModel.isReadOnly) // Act:横幅"开新会话"(同一 coordinator 动作)。 coordinator.openNewSessionInCurrentCwd() // Assert:同 cwd 的 fresh spawn。 let second = try #require(coordinator.terminalController) await awaitAttachSettled(second) let byConnection = await fixture.transport.sentFramesByConnection #expect(byConnection[1] == [ MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")) ]) second.teardown() } @Test("横幅'开新会话'只在 exited 态可用(failed/连接态不提供)") func bannerNewSessionAffordanceOnlyWhenExited() { #expect(ReconnectBanner.isNewSessionActionAvailable(for: .exited(code: 0, reason: nil))) #expect(ReconnectBanner.isNewSessionActionAvailable( for: .exited(code: -1, reason: "spawn failed") )) #expect(!ReconnectBanner.isNewSessionActionAvailable(for: .connecting)) #expect(!ReconnectBanner.isNewSessionActionAvailable( for: .reconnecting(attempt: 1, retryIn: .seconds(1)) )) #expect(!ReconnectBanner.isNewSessionActionAvailable(for: .failed(message: "x"))) } }