Files
web-terminal/ios/App/WebTermTests/NewSessionInCwdTests.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

291 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// closeopen `attach(null, cwd)` web tabs.ts
/// `newTab()` M6 cwdcwd /live-sessions
/// server-adopted id controller spawnCwdT-iOS-26
/// fresh-spawn
/// ProjectsViewModel
/// 2. exited + exit TerminalViewModel P0 +
/// ""src/session/manager.ts:145-153exited
/// 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: - FixturesSessionSwitcherTests 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 adoptedopenTask 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()
// Assertcloseopen 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.spawnCwdbootstrap 绝不复注入")
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
// Arrange fresh spawnspawnCwd + 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-153reap
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)
// Assertexit
#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")))
}
}