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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

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