feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
This commit is contained in:
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
@@ -0,0 +1,290 @@
|
||||
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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user