Files
web-terminal/ios/App/WebTermTests/SidebarSelectionTests.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

306 lines
14 KiB
Swift
Raw 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-iPad-2 · split sidebar `AppCoordinator` **
/// ** UI
/// `open(id)` / `open(nil)` / `presentProjects()`
///
/// - `.session(id)` == `open(id)` `attach(sessionId:id, cwd:nil)`
/// - `.newSession` == `open(nil)` `attach(sessionId:nil, cwd:nil)`
/// - `.projects` == Projects `isProjectsPresented`
///
/// /`selectedSidebarItem`
/// adopted = `.session(adoptedId)` nil沿 WS
/// closeopen `closeTerminal` last-seen + `open`****
///
/// `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport
/// openTask + waitUntilProcessed NewSessionInCwdTests
@MainActor
@Suite("SidebarSelection (T-iPad-2)")
struct SidebarSelectionTests {
private nonisolated static let base = "http://192.168.1.5:3000"
// MARK: - FixturesNewSessionInCwdTests / SessionSwitcherTests
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: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
unreadStore: unreadStore
)
)
}
/// activeHost 线sidebar
private func primeHost(_ coordinator: AppCoordinator) async {
await coordinator.sessionList.reloadHosts()
}
/// attach adoptedopenTask 3
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. SidebarItem coordinator API
@Test("选 .session(id) == 现有 open(id):首帧 attach(sessionId:id, cwd:nil),单一连接")
func sessionItemOpensSameAsOpenId() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.sessionItem")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionId = UUID()
// Actsidebar split
coordinator.selectSidebarItem(.session(sessionId))
// Assert open startTerminal id attach
let controller = try #require(coordinator.terminalController)
await awaitAttachSettled(controller)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 1) // engine
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
controller.teardown()
}
@Test("选 .newSession == 现有 open(nil):首帧 attach(sessionId:nil, cwd:nil)")
func newSessionItemOpensFreshSession() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.newSession")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// Act
coordinator.selectSidebarItem(.newSession)
// Assert
let controller = try #require(coordinator.terminalController)
await awaitAttachSettled(controller)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 1)
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
controller.teardown()
}
@Test("选 .projects == 现有 Projects 呈现:置位 isProjectsPresented不开终端")
func projectsItemPresentsProjects() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.projects")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// Act
coordinator.selectSidebarItem(.projects)
// Assert presentProjects terminalController
#expect(coordinator.isProjectsPresented)
#expect(coordinator.projectsViewModel != nil)
#expect(coordinator.terminalController == nil)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.isEmpty)
}
@Test("无 activeHost → .session/.newSession 均 no-op不 spawn、不连接")
func withoutActiveHostIsNoOp() async throws {
// Arrange reloadHosts activeHost nil
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.noHost")
let coordinator = AppCoordinator(environment: fixture.environment)
// Act
coordinator.selectSidebarItem(.session(UUID()))
coordinator.selectSidebarItem(.newSession)
// Assert
#expect(coordinator.terminalController == nil)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.isEmpty)
}
// MARK: - 2. selectedSidebarItem /
@Test("打开终端 → selectedSidebarItem = .session(adoptedId)closeTerminal → 归 nil")
func selectionReflectsOpenAndClose() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reflect")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
//
#expect(coordinator.selectedSidebarItem == nil)
// Act adopted
coordinator.selectSidebarItem(.newSession)
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Assert adopted detail
#expect(coordinator.selectedSidebarItem == .session(adopted))
// Act detail
coordinator.closeTerminal()
// Assert detail
#expect(coordinator.selectedSidebarItem == nil)
}
@Test("killCurrentSession有 adopted 会话 → detach detail归 nil无会话 → no-op")
func killCurrentSessionClosesDetail() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.kill")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// kill = no-opdetail
coordinator.killCurrentSession()
#expect(coordinator.terminalController == nil)
// Act adopt kill
coordinator.selectSidebarItem(.newSession)
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: UUID())
coordinator.killCurrentSession()
// Assertdetail detachkill SessionListViewModel.kill
// APIClient.killSession Origin T-iOS-8
#expect(coordinator.terminalController == nil)
#expect(coordinator.selectedSidebarItem == nil)
}
@Test("sidebarSelection 绑定 get/set 往返set(.newSession) 触发打开get 反映 adopted")
func bindingRoundTrips() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.binding")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
let binding = coordinator.sidebarSelection
// Act setter == NavigationSplitView
binding.wrappedValue = .newSession
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Assertgetter adopted
#expect(binding.wrappedValue == .session(adopted))
controller.teardown()
}
// MARK: - 3. closeopen
@Test("sidebar 选另一会话 → closeTerminal(记 last-seen)+open新 controller、第二连接 attach B")
func switchingSessionsReusesCloseThenOpen() async throws {
// Arrange A adopted
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.switch")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionA = UUID()
let sessionB = UUID()
coordinator.selectSidebarItem(.session(sessionA))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionA)
// Actsidebar Bdetail
coordinator.selectSidebarItem(.session(sessionB))
// Assert WS close(A)open(B) controller attach B
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: sessionB, cwd: nil))])
// closeTerminal A last-seen
#expect(fixture.unreadStore.snapshot[sessionA] != nil)
second.teardown()
}
@Test("重复选中当前 detail 会话 → no-op不 close/open无第二连接")
func reselectingCurrentSessionIsNoOp() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reselect")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionA = UUID()
coordinator.selectSidebarItem(.session(sessionA))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionA)
// Act
coordinator.selectSidebarItem(.session(sessionA))
// Assertcontroller churn
#expect(coordinator.terminalController === first)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.count == 1)
first.teardown()
}
// MARK: - 4. T-iOS-29 .id
@Test("regular→compact 切换Slide Overcontroller 归 coordinator 所有,实例不变、选中态保持")
func controllerIdentityStableAcrossLayoutSwitch() async throws {
// Arrange adopted
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.identity")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
coordinator.selectSidebarItem(.newSession)
let captured = try #require(coordinator.terminalController)
await adopt(captured, transport: fixture.transport, sessionId: adopted)
// Act iPad / Slide Over AdaptiveRootView
// splitstack coordinator
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
// Assertcontroller T-iOS-29 .id(controller.id)
// controller
#expect(coordinator.terminalController === captured)
#expect(coordinator.selectedSidebarItem == .session(adopted))
captured.teardown()
}
}