T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
291 lines
13 KiB
Swift
291 lines
13 KiB
Swift
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")))
|
||
}
|
||
}
|