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.
91 lines
3.6 KiB
Swift
91 lines
3.6 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import HostRegistry
|
||
import SessionCore
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-26 · "在此仓库开新会话" 的接线证明:`TerminalSessionController` 以
|
||
/// `spawnCwd` + `bootstrapInput` 启动时,线上帧序 = `attach(null, cwd)` →
|
||
/// `input("claude\r")`(engine 的 attach-first 队列语义保证 bootstrap 绝不
|
||
/// 先于 attach 出线);带 sessionId 的常规打开则既无 cwd 也无 bootstrap。
|
||
///
|
||
/// 真 `SessionEngine` over `FakeTransport`(零真实等待):帧断言经
|
||
/// `openTask` + `waitUntilProcessed` 双屏障(open+send 已提交 ∧ attach
|
||
/// 握手已完成 → 帧必已落地)。
|
||
@MainActor
|
||
@Suite("ProjectOpenWiring")
|
||
struct ProjectOpenWiringTests {
|
||
private struct Fixture {
|
||
let transport: FakeTransport
|
||
let host: HostRegistry.Host
|
||
let environment: AppEnvironment
|
||
}
|
||
|
||
private func makeFixture() throws -> Fixture {
|
||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let transport = FakeTransport()
|
||
let defaults = try #require(UserDefaults(suiteName: "ProjectOpenWiringTests"))
|
||
return Fixture(
|
||
transport: transport,
|
||
host: HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint),
|
||
environment: AppEnvironment(
|
||
hostStore: InMemoryHostStore(),
|
||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||
http: FakeHTTPTransport(),
|
||
termTransport: transport,
|
||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) })
|
||
)
|
||
)
|
||
}
|
||
|
||
/// 双屏障:controller 的 open+bootstrap Task 完成(send 已提交/入队)∧
|
||
/// attach 握手完成(.connecting/.connected 已到 VM → 队列已冲刷)。
|
||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||
await controller.openTask?.value
|
||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||
}
|
||
|
||
@Test("spawn 变体:帧序 = attach(null, cwd) → input(claude\\r)")
|
||
func spawnSendsAttachWithCwdThenBootstrapInput() async throws {
|
||
let fixture = try makeFixture()
|
||
let controller = TerminalSessionController(
|
||
host: fixture.host, sessionId: nil, environment: fixture.environment,
|
||
onPendingChanged: { _, _ in },
|
||
spawnCwd: "/repos/api",
|
||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||
)
|
||
|
||
controller.start()
|
||
await awaitAttachSettled(controller)
|
||
|
||
let frames = await fixture.transport.sentFrames
|
||
#expect(frames == [
|
||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")),
|
||
MessageCodec.encode(.input(data: ProjectLaunch.claudeBootstrapInput)),
|
||
])
|
||
controller.teardown()
|
||
}
|
||
|
||
@Test("常规打开(带 sessionId):只有 attach,无 cwd、无 bootstrap")
|
||
func plainOpenSendsBareAttach() async throws {
|
||
let fixture = try makeFixture()
|
||
let sessionId = UUID()
|
||
let controller = TerminalSessionController(
|
||
host: fixture.host, sessionId: sessionId,
|
||
environment: fixture.environment,
|
||
onPendingChanged: { _, _ in }
|
||
)
|
||
|
||
controller.start()
|
||
await awaitAttachSettled(controller)
|
||
|
||
let frames = await fixture.transport.sentFrames
|
||
#expect(frames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
|
||
controller.teardown()
|
||
}
|
||
}
|