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.
This commit is contained in:
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal file
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal file
@@ -0,0 +1,346 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The app's single read path for per-host access tokens, plus the one
|
||||
/// header-stamping point (`AccessTokenCookie`).
|
||||
///
|
||||
/// E1 · The WS cases are the important ones. SessionCore's `tokenProvider` is
|
||||
/// `() -> String?` — no endpoint, no await — so C1 answered it with "the token
|
||||
/// every paired host shares", which is *nil* for the deployment the token
|
||||
/// exists for (a tokened tunnel host next to an open LAN host): the upgrade then
|
||||
/// omitted the cookie, the server 401'd, and `.failed(.unauthorized)` is
|
||||
/// terminal — with no in-app remedy, since re-entering the correct token left
|
||||
/// the fleet just as mixed. The answer is now resolved PER HOST
|
||||
/// (`wsToken(for:)`), exactly like the HTTP path and like Android's
|
||||
/// `tokens.tokenFor(endpoint)`.
|
||||
@Suite("Access-token source")
|
||||
struct AccessTokenSourceTests {
|
||||
private static let tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
private static let tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
|
||||
private actor ThrowingHostStore: HostStore {
|
||||
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
throw StoreFailure()
|
||||
}
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
}
|
||||
|
||||
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: base,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeStore(_ hosts: [HostRegistry.Host]) async throws -> InMemoryHostStore {
|
||||
let store = InMemoryHostStore()
|
||||
for host in hosts {
|
||||
_ = try await store.upsert(host)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// MARK: - Per-origin resolution (the HTTP path)
|
||||
|
||||
@Test("按 origin 取到该主机的令牌;其它 origin 与无令牌主机都返回 nil")
|
||||
func resolvesTokenByOrigin() async throws {
|
||||
let store = try await makeStore([
|
||||
try makeHost("http://192.168.1.5:3000", token: Self.tokenA),
|
||||
try makeHost("http://192.168.1.9:3000", token: nil),
|
||||
])
|
||||
let source = AccessTokenSource(store: store)
|
||||
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == Self.tokenA)
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.9:3000") == nil)
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.99:3000") == nil)
|
||||
}
|
||||
|
||||
@Test("https 默认端口按 originHeader 规范化匹配(不写 :443)")
|
||||
func matchesNormalizedHTTPSOrigin() async throws {
|
||||
let store = try await makeStore([
|
||||
try makeHost("https://mac.tailnet.ts.net", token: Self.tokenA),
|
||||
])
|
||||
let source = AccessTokenSource(store: store)
|
||||
|
||||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net") == Self.tokenA)
|
||||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net:443") == nil)
|
||||
}
|
||||
|
||||
@Test("存储读取失败 → nil(降级成「无令牌」,不让 App 崩或卡住)")
|
||||
func storeFailureDegradesToNoToken() async throws {
|
||||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||||
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == nil)
|
||||
}
|
||||
|
||||
// MARK: - The per-host WS answer (E1 · replaces `hostIndependentToken`)
|
||||
|
||||
@Test("混合机群:有令牌的那台拿到自己的令牌,没令牌的那台拿 nil(旧解析对两台都给 nil)")
|
||||
func resolvesPerHostTokenInAMixedFleet() async throws {
|
||||
let tokened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||||
let source = AccessTokenSource(store: try await makeStore([tokened, open]))
|
||||
|
||||
_ = await source.token(forOrigin: tokened.endpoint.originHeader) // 快照热身
|
||||
|
||||
#expect(source.wsToken(for: tokened) == Self.tokenA)
|
||||
#expect(source.wsToken(for: open) == nil)
|
||||
}
|
||||
|
||||
@Test("两台令牌不同 → 各拿各的,绝不把 A 的令牌发给 B")
|
||||
func neverHandsOneHostsTokenToAnother() async throws {
|
||||
let hostA = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let hostB = try makeHost("http://192.168.1.9:3000", token: Self.tokenB)
|
||||
let source = AccessTokenSource(store: try await makeStore([hostA, hostB]))
|
||||
|
||||
_ = await source.token(forOrigin: hostA.endpoint.originHeader)
|
||||
|
||||
#expect(source.wsToken(for: hostA) == Self.tokenA)
|
||||
#expect(source.wsToken(for: hostB) == Self.tokenB)
|
||||
}
|
||||
|
||||
@Test("快照还没热(刚启动就开终端)→ 回落到该主机记录,绝不因竞态漏掉 Cookie")
|
||||
func fallsBackToTheHostRecordBeforeAnyStoreRead() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||||
|
||||
// No `token(forOrigin:)` call yet — the snapshot is empty.
|
||||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||||
}
|
||||
|
||||
@Test("令牌轮换后:下一次连接用存储里的新值,而不是打开终端时的旧值")
|
||||
func picksUpARotatedTokenOnTheNextConnect() async throws {
|
||||
let opened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let store = try await makeStore([opened])
|
||||
let source = AccessTokenSource(store: store)
|
||||
let rotated = HostRegistry.Host(
|
||||
id: opened.id, name: opened.name, endpoint: opened.endpoint,
|
||||
accessToken: try AccessToken(validating: Self.tokenB)
|
||||
)
|
||||
_ = try await store.upsert(rotated)
|
||||
|
||||
_ = await source.token(forOrigin: opened.endpoint.originHeader) // 任一 HTTP 请求即刷新
|
||||
|
||||
#expect(source.wsToken(for: opened) == Self.tokenB)
|
||||
}
|
||||
|
||||
@Test("存储读取失败 → 回落到主机记录(打开时的值),不是别的主机的令牌")
|
||||
func wsTokenSurvivesAStoreFailure() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||||
|
||||
#expect(await source.token(forOrigin: host.endpoint.originHeader) == nil)
|
||||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||||
}
|
||||
|
||||
@Test("完全没有令牌的机群 → nil(LAN 零配置逐字不变,不带 Cookie)")
|
||||
func tokenlessFleetYieldsNoCookie() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: nil)
|
||||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||||
|
||||
_ = await source.token(forOrigin: host.endpoint.originHeader)
|
||||
|
||||
#expect(source.wsToken(for: host) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
|
||||
/// transport is built for THE host it dials, not once for the whole app.
|
||||
@MainActor
|
||||
@Suite("Per-host WS transport wiring")
|
||||
struct PerHostTermTransportTests {
|
||||
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
|
||||
private final class HostRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var hosts: [HostRegistry.Host] = []
|
||||
|
||||
func record(_ host: HostRegistry.Host) {
|
||||
lock.withLock { hosts.append(host) }
|
||||
}
|
||||
|
||||
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
|
||||
}
|
||||
|
||||
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(), name: base,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeEnvironment(
|
||||
shared: FakeTransport,
|
||||
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
|
||||
) throws -> AppEnvironment {
|
||||
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
|
||||
return AppEnvironment(
|
||||
hostStore: InMemoryHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: shared,
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
termTransportFactory: factory
|
||||
)
|
||||
}
|
||||
|
||||
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
|
||||
func fallsBackToTheSharedTransport() throws {
|
||||
let shared = FakeTransport()
|
||||
let environment = try makeEnvironment(shared: shared)
|
||||
|
||||
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
|
||||
|
||||
#expect(resolved as? FakeTransport === shared)
|
||||
}
|
||||
|
||||
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
|
||||
func buildsOneTransportPerHost() throws {
|
||||
let recorder = HostRecorder()
|
||||
let environment = try makeEnvironment(
|
||||
shared: FakeTransport(),
|
||||
factory: { host in
|
||||
recorder.record(host)
|
||||
return FakeTransport()
|
||||
}
|
||||
)
|
||||
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
|
||||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||||
|
||||
_ = environment.makeTermTransport(for: tokened)
|
||||
_ = environment.makeTermTransport(for: open)
|
||||
|
||||
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
|
||||
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
|
||||
}
|
||||
|
||||
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
|
||||
func controllerBuildsItsTransportForItsOwnHost() throws {
|
||||
let recorder = HostRecorder()
|
||||
let environment = try makeEnvironment(
|
||||
shared: FakeTransport(),
|
||||
factory: { host in
|
||||
recorder.record(host)
|
||||
return FakeTransport()
|
||||
}
|
||||
)
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
|
||||
|
||||
_ = TerminalSessionController(
|
||||
host: host, sessionId: nil, environment: environment,
|
||||
onPendingChanged: { _, _ in }
|
||||
)
|
||||
|
||||
#expect(recorder.recorded.map(\.id) == [host.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// C1 · The App layer's one place where a token becomes a header.
|
||||
@Suite("Access-token cookie stamping")
|
||||
struct AccessTokenCookieTests {
|
||||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
private func request(_ url: String) throws -> URLRequest {
|
||||
URLRequest(url: try #require(URL(string: url)))
|
||||
}
|
||||
|
||||
@Test("请求 URL → originHeader 形式的 origin(路径/查询串被忽略)")
|
||||
func derivesOriginFromRequestURL() throws {
|
||||
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
|
||||
|
||||
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
|
||||
}
|
||||
|
||||
@Test("https 默认端口不写 :443(与服务器的 URL 规范化一致)")
|
||||
func omitsDefaultHTTPSPort() throws {
|
||||
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
|
||||
|
||||
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
|
||||
}
|
||||
|
||||
@Test("非 http(s) / 无 host 的请求 → nil(不可能是本 App 的主机)")
|
||||
func rejectsNonHTTPRequests() throws {
|
||||
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
|
||||
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
|
||||
}
|
||||
|
||||
@Test("盖上 Cookie: webterm_auth=<t>,且返回新请求(不原地改调用方的请求)")
|
||||
func stampsCookieImmutably() throws {
|
||||
let original = try request("http://192.168.1.5:3000/live-sessions")
|
||||
|
||||
let stamped = AccessTokenCookie.stamped(original, token: Self.token)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(original.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
|
||||
@Test("已经带 Cookie 的请求原样通过(配对探针的候选令牌不能被覆盖)")
|
||||
func neverOverwritesAnExistingCookie() throws {
|
||||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||||
candidate.setValue("webterm_auth=candidate-token-value", forHTTPHeaderField: "Cookie")
|
||||
|
||||
let stamped = AccessTokenCookie.stamped(candidate, token: Self.token)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate-token-value")
|
||||
}
|
||||
|
||||
@Test("Cookie 名与服务器 AUTH_COOKIE_NAME 逐字一致")
|
||||
func cookieNameMatchesTheServer() {
|
||||
#expect(AccessTokenCookie.name == "webterm_auth")
|
||||
}
|
||||
|
||||
// MARK: - The transport choke point itself (no network involved)
|
||||
|
||||
@Test("传输层给每个请求按 origin 盖上令牌 Cookie(RO GET 也一样)")
|
||||
func transportStampsEveryRequest() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil },
|
||||
tokenForOrigin: { origin in
|
||||
origin == "http://192.168.1.5:3000" ? Self.token : nil
|
||||
}
|
||||
)
|
||||
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
|
||||
|
||||
let stamped = await transport.authenticated(readOnly)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
}
|
||||
|
||||
@Test("没有该 origin 的令牌 → 请求逐字不变(LAN 零配置主机不受影响)")
|
||||
func transportLeavesTokenlessOriginsAlone() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil }, tokenForOrigin: { _ in nil }
|
||||
)
|
||||
let plain = try request("http://192.168.1.9:3000/live-sessions")
|
||||
|
||||
let result = await transport.authenticated(plain)
|
||||
|
||||
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
|
||||
}
|
||||
|
||||
@Test("请求已带 Cookie(配对探针的候选令牌)→ 传输层不覆盖")
|
||||
func transportNeverOverwritesTheProbeCandidate() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
|
||||
)
|
||||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||||
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
|
||||
|
||||
let result = await transport.authenticated(candidate)
|
||||
|
||||
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
|
||||
}
|
||||
}
|
||||
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
@@ -0,0 +1,353 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-34 · 主题(跟随系统 / 深色 / 浅色)—— 模型、持久化、有效配色解析,
|
||||
/// 以及**浅色通路的 token 审计**(doc §4 A–E 条目 1–24)。
|
||||
///
|
||||
/// 审计的核心不是"把 `.preferredColorScheme(.dark)` 解锁",而是:每个语义色在
|
||||
/// 浅色档下都要有**能看清**的值。故 D 组用 WCAG 对比度(非文本 UI 组件门槛
|
||||
/// 1.4.11 = 3:1;终端正文按 AAA = 7:1)逐档量化,深色档同时**逐字节钉死**旧值
|
||||
/// 保证零回归。
|
||||
@MainActor
|
||||
@Suite("AppTheme")
|
||||
struct AppThemeTests {
|
||||
|
||||
// MARK: - A. 主题模型
|
||||
|
||||
@Test("colorScheme:.system 交给系统(nil),.dark/.light 强制自身")
|
||||
func colorSchemePerCase() {
|
||||
#expect(AppTheme.system.colorScheme == nil)
|
||||
#expect(AppTheme.dark.colorScheme == .dark)
|
||||
#expect(AppTheme.light.colorScheme == .light)
|
||||
}
|
||||
|
||||
@Test("三档 label / SF Symbol 非空且互不相同")
|
||||
func labelsAndSymbolsAreDistinct() {
|
||||
let labels = AppTheme.allCases.map(\.label)
|
||||
let symbols = AppTheme.allCases.map(\.symbolName)
|
||||
#expect(labels.allSatisfy { !$0.isEmpty })
|
||||
#expect(symbols.allSatisfy { !$0.isEmpty })
|
||||
#expect(Set(labels).count == AppTheme.allCases.count)
|
||||
#expect(Set(symbols).count == AppTheme.allCases.count)
|
||||
}
|
||||
|
||||
@Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)")
|
||||
func caseOrderIsPickerOrder() {
|
||||
#expect(AppTheme.allCases == [.system, .dark, .light])
|
||||
}
|
||||
|
||||
@Test("rawValue 白名单:只认三个小写标识")
|
||||
func rawValueWhitelist() {
|
||||
#expect(AppTheme(rawValue: "system") == .system)
|
||||
#expect(AppTheme(rawValue: "dark") == .dark)
|
||||
#expect(AppTheme(rawValue: "light") == .light)
|
||||
#expect(AppTheme(rawValue: "") == nil)
|
||||
#expect(AppTheme(rawValue: "Dark") == nil)
|
||||
#expect(AppTheme(rawValue: "solarized") == nil)
|
||||
}
|
||||
|
||||
// MARK: - B. 持久化
|
||||
|
||||
@Test("未设置 → .dark(默认必须等于今天硬锁的深色,零回归)")
|
||||
func decodeMissingIsDark() {
|
||||
#expect(AppThemeCodec.decode(nil) == .dark)
|
||||
#expect(AppThemeCodec.fallback == .dark)
|
||||
}
|
||||
|
||||
@Test("脏值 / 空串 / 大小写不符 → .dark,不崩、不落 system")
|
||||
func decodeGarbageIsDark() {
|
||||
#expect(AppThemeCodec.decode("solarized") == .dark)
|
||||
#expect(AppThemeCodec.decode("") == .dark)
|
||||
#expect(AppThemeCodec.decode("DARK") == .dark)
|
||||
#expect(AppThemeCodec.decode("\u{0}light") == .dark)
|
||||
}
|
||||
|
||||
@Test("三档 encode → decode 往返恒等")
|
||||
func codecRoundTrips() {
|
||||
for theme in AppTheme.allCases {
|
||||
#expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("空 defaults → .dark,且首次读不写盘")
|
||||
func storeStartsDarkWithoutWriting() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
#expect(store.theme == .dark)
|
||||
#expect(defaults.writeCount == 0)
|
||||
}
|
||||
|
||||
@Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)")
|
||||
func selectPersistsAcrossStores() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
store.select(.light)
|
||||
|
||||
#expect(store.theme == .light)
|
||||
#expect(defaults.values[ThemeStore.storageKey] == "light")
|
||||
#expect(ThemeStore(defaults: defaults).theme == .light)
|
||||
}
|
||||
|
||||
@Test("select 同一档两次 → 只写一次")
|
||||
func repeatedSelectWritesOnce() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
store.select(.light)
|
||||
store.select(.light)
|
||||
|
||||
#expect(defaults.writeCount == 1)
|
||||
}
|
||||
|
||||
@Test("defaults 里是脏值 → 读作 .dark,且不动其它键")
|
||||
func dirtyStoredValueDegradesToDark() {
|
||||
let defaults = FakeThemeDefaults(values: [
|
||||
ThemeStore.storageKey: "; rm -rf ~",
|
||||
"unrelated.key": "keep-me",
|
||||
])
|
||||
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
#expect(store.theme == .dark)
|
||||
#expect(defaults.values["unrelated.key"] == "keep-me")
|
||||
}
|
||||
|
||||
// MARK: - C. 有效配色解析
|
||||
|
||||
@Test("resolvedScheme:.system 透传系统值,.dark/.light 无视系统")
|
||||
func resolvedScheme() {
|
||||
#expect(AppTheme.system.resolvedScheme(system: .light) == .light)
|
||||
#expect(AppTheme.system.resolvedScheme(system: .dark) == .dark)
|
||||
#expect(AppTheme.dark.resolvedScheme(system: .light) == .dark)
|
||||
#expect(AppTheme.light.resolvedScheme(system: .dark) == .light)
|
||||
}
|
||||
|
||||
// MARK: - D. 浅色通路 token 审计
|
||||
|
||||
/// 受审的 5 个语义色(状态三色 + 时间线两色)——它们原本都是单值深色专用。
|
||||
private static let semanticColors: [(name: String, color: Color)] = [
|
||||
("statusWorking", DS.Palette.statusWorking),
|
||||
("statusWaiting", DS.Palette.statusWaiting),
|
||||
("statusStuck", DS.Palette.statusStuck),
|
||||
("timelineTool", DS.Palette.timelineTool),
|
||||
("timelineUser", DS.Palette.timelineUser),
|
||||
]
|
||||
|
||||
@Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)")
|
||||
func semanticColorsAreAdaptive() {
|
||||
for (name, color) in Self.semanticColors {
|
||||
let dark = UIColor(color).resolved(.dark)
|
||||
let light = UIColor(color).resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF)")
|
||||
func darkSchemeValuesUnchanged() {
|
||||
let expected: [(Color, UInt32)] = [
|
||||
(DS.Palette.statusWorking, 0x46D0_7F),
|
||||
(DS.Palette.statusWaiting, 0xF5B1_4C),
|
||||
(DS.Palette.statusStuck, 0xFF6B_6B),
|
||||
(DS.Palette.timelineTool, 0x5E9E_FF),
|
||||
(DS.Palette.timelineUser, 0xAF7B_FF),
|
||||
]
|
||||
for (color, hex) in expected {
|
||||
let resolved = UIColor(color).resolved(.dark)
|
||||
#expect(
|
||||
ColorMath.matchesHex(resolved, hex),
|
||||
"深色档漂移:实际 \(ColorMath.hexString(resolved))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1(WCAG 1.4.11)")
|
||||
func semanticColorsMeetNonTextContrast() {
|
||||
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||
let background = UIColor.systemBackground.resolved(style)
|
||||
for (name, color) in Self.semanticColors {
|
||||
let ratio = ColorMath.contrast(UIColor(color).resolved(style), background)
|
||||
#expect(ratio >= 3, "\(name) 在 \(style == .dark ? "dark" : "light") 只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("light 档 working/waiting/stuck 仍两两可辨")
|
||||
func criticalTrioStaysDistinctInLight() {
|
||||
let working = UIColor(DS.Palette.statusWorking).resolved(.light)
|
||||
let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light)
|
||||
let stuck = UIColor(DS.Palette.statusStuck).resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(working, waiting))
|
||||
#expect(!ColorMath.approxEqual(working, stuck))
|
||||
#expect(!ColorMath.approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("accentSoft 两档不同,且都仍是 wash(alpha < 0.3)")
|
||||
func accentSoftIsAdaptiveWash() {
|
||||
let soft = UIColor(DS.Palette.accentSoft)
|
||||
let dark = soft.resolved(.dark)
|
||||
let light = soft.resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(dark, light))
|
||||
#expect(ColorMath.rgba(dark).a < 0.3)
|
||||
#expect(ColorMath.rgba(light).a < 0.3)
|
||||
}
|
||||
|
||||
@Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1")
|
||||
func onAccentIsFixedAndReadable() {
|
||||
let ink = UIColor(DS.Palette.onAccent)
|
||||
#expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light)))
|
||||
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||
let ratio = ColorMath.contrast(
|
||||
ink.resolved(style), DS.Palette.accentUIColor().resolved(style)
|
||||
)
|
||||
#expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - E. 终端画布跟随主题
|
||||
|
||||
@Test("dark 档终端 = #100F0D / #ECE9E3(桌面 web --bg/--text,零回归)")
|
||||
func terminalDarkMatchesDesktop() {
|
||||
let colors = TerminalPalette.colors(for: .dark)
|
||||
#expect(ColorMath.matchesHex(colors.background, 0x100F_0D),
|
||||
"实际 \(ColorMath.hexString(colors.background))")
|
||||
#expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3),
|
||||
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||
}
|
||||
|
||||
@Test("light 档终端 = #F6F7F9 / #1A1D24(镜像 web THEMES.light)")
|
||||
func terminalLightMirrorsWeb() {
|
||||
let colors = TerminalPalette.colors(for: .light)
|
||||
#expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9),
|
||||
"实际 \(ColorMath.hexString(colors.background))")
|
||||
#expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24),
|
||||
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||
}
|
||||
|
||||
@Test("两档终端 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA)")
|
||||
func terminalContrastIsAAA() {
|
||||
for scheme in [ColorScheme.dark, .light] {
|
||||
let colors = TerminalPalette.colors(for: scheme)
|
||||
let ratio = ColorMath.contrast(colors.foreground, colors.background)
|
||||
#expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("caret / selection 用该档 accent 解析值")
|
||||
func terminalCaretFollowsAccent() {
|
||||
for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark),
|
||||
(ColorScheme.light, UIUserInterfaceStyle.light)] {
|
||||
let colors = TerminalPalette.colors(for: scheme)
|
||||
let accent = DS.Palette.accentUIColor().resolved(style)
|
||||
#expect(ColorMath.approxEqual(colors.caret, accent))
|
||||
#expect(ColorMath.approxEqual(colors.selection, accent))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor(两档不同)")
|
||||
func terminalTokensAreDynamic() {
|
||||
for token in [DS.Palette.terminalBackgroundUIColor(),
|
||||
DS.Palette.terminalForegroundUIColor()] {
|
||||
#expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)")
|
||||
func terminalSwiftUITokensStayDynamic() {
|
||||
for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] {
|
||||
let bridged = UIColor(color)
|
||||
#expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fake defaults
|
||||
|
||||
/// `ThemeDefaults` 测试替身:记录写次数("首次读不写盘"、"重复 select 只写一次"
|
||||
/// 两条断言都靠它),并保留其它键以证明 store 不会越界清理。
|
||||
final class FakeThemeDefaults: ThemeDefaults {
|
||||
private(set) var values: [String: String]
|
||||
private(set) var writeCount = 0
|
||||
|
||||
init(values: [String: String] = [:]) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
func themeRaw(forKey key: String) -> String? { values[key] }
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
values = values.merging([key: value]) { _, new in new }
|
||||
writeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color math (WCAG)
|
||||
|
||||
/// 颜色解析 + WCAG 相对亮度/对比度。放在测试侧:生产代码不需要算对比度,
|
||||
/// 但审计必须**量化**("看着还行"不是验收)。公式取 WCAG 2.1 定义。
|
||||
enum ColorMath {
|
||||
typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||||
|
||||
static func rgba(_ color: UIColor) -> RGBA {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (r, g, b, a)
|
||||
}
|
||||
|
||||
static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool {
|
||||
let left = rgba(lhs), right = rgba(rhs)
|
||||
return abs(left.r - right.r) < tolerance
|
||||
&& abs(left.g - right.g) < tolerance
|
||||
&& abs(left.b - right.b) < tolerance
|
||||
&& abs(left.a - right.a) < tolerance
|
||||
}
|
||||
|
||||
/// WCAG 相对亮度(sRGB → 线性)。
|
||||
static func luminance(_ color: UIColor) -> CGFloat {
|
||||
let components = rgba(color)
|
||||
func linear(_ channel: CGFloat) -> CGFloat {
|
||||
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(components.r)
|
||||
+ 0.7152 * linear(components.g)
|
||||
+ 0.0722 * linear(components.b)
|
||||
}
|
||||
|
||||
/// WCAG 对比度(1:1…21:1),顺序无关。
|
||||
static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat {
|
||||
let a = luminance(lhs), b = luminance(rhs)
|
||||
let (lighter, darker) = a > b ? (a, b) : (b, a)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/// 逐字节(±1/255)比对一个具体色与 `0xRRGGBB`。
|
||||
static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool {
|
||||
let components = rgba(color)
|
||||
let expected = (
|
||||
r: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
g: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
b: CGFloat(hex & 0xFF) / 255
|
||||
)
|
||||
return abs(components.r - expected.r) < tolerance
|
||||
&& abs(components.g - expected.g) < tolerance
|
||||
&& abs(components.b - expected.b) < tolerance
|
||||
}
|
||||
|
||||
/// 失败时把实际值印成 `#RRGGBB`,便于一眼看出差多少。
|
||||
static func hexString(_ color: UIColor) -> String {
|
||||
let components = rgba(color)
|
||||
let value = (Int(components.r * 255) << 16)
|
||||
| (Int(components.g * 255) << 8) | Int(components.b * 255)
|
||||
return String(format: "#%06X", value)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
/// 按指定外观解析动态色(审计的基本动作)。
|
||||
func resolved(_ style: UIUserInterfaceStyle) -> UIColor {
|
||||
resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
|
||||
}
|
||||
}
|
||||
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-35 · web 分享 QR(`?join=`)互通(doc §4 A–C 组,条目 30–50)。
|
||||
///
|
||||
/// web 侧的分享 URL 形状是 `${location.origin}/?join=${sessionId}`
|
||||
/// (`public/share.ts:55`)。手机拿到这个 URL 时它是**不可信外部输入**(二维码
|
||||
/// 里可以是任何东西),所以 http(s) 分支比自定义 scheme 分支**更严**:
|
||||
/// scheme/host/path/query 键逐项白名单、query 精确只许一个 `join`、拒 fragment、
|
||||
/// 拒 userinfo,`sessionId` 仍只经冻结的 `Validation.isValidSessionId`。
|
||||
///
|
||||
/// 主机身份**只**经 `HostStore` 解析:origin 比对用 `HostEndpoint.originHeader`
|
||||
/// (冻结的唯一 origin 派生点),未配对 → 落配对流程,**绝不**照链接内容静默新增主机。
|
||||
@MainActor
|
||||
@Suite("DeepLinkJoin")
|
||||
struct DeepLinkJoinTests {
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||
|
||||
private static func url(_ string: String) throws -> URL {
|
||||
try #require(URL(string: string))
|
||||
}
|
||||
|
||||
// MARK: - A. 接受 web 分享形状
|
||||
|
||||
@Test("http://<ip>:3000/?join=<v4> → .joinShared(origin, sessionId)")
|
||||
func lanShareLinkRoutes() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("http://192.168.1.5:3000/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "http://192.168.1.5:3000", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("https 默认端口不出现在 origin 里")
|
||||
func httpsDefaultPortOmitted() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://mac.tailnet.ts.net/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "https://mac.tailnet.ts.net", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("大写 scheme/host → origin 归一化为小写")
|
||||
func originIsNormalizedLowercase() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("HTTP://MAC.LOCAL:3000/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "http://mac.local:3000", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("path 为空或 \"/\" 都接受(origin + \"/?join=\" 产出 /)")
|
||||
func emptyAndRootPathsAccepted() throws {
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
let expected = DeepLinkRouter.Route.joinShared(
|
||||
origin: "http://mac.local:3000", sessionId: sessionId
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local:3000/?join=\(Self.validSessionId)")
|
||||
) == expected
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local:3000?join=\(Self.validSessionId)")
|
||||
) == expected
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - B. 白名单纪律
|
||||
|
||||
@Test("join 值非 v4 → .ignore(复用 Validation,不再造正则)")
|
||||
func nonV4JoinIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=\(Self.v1StyleId)"))
|
||||
== .ignore
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=not-a-uuid")) == .ignore
|
||||
)
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=")) == .ignore)
|
||||
}
|
||||
|
||||
@Test("重复 join 键 → .ignore(歧义输入绝不部分应用)")
|
||||
func duplicateJoinIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"http://mac.local/?join=\(Self.validSessionId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("多余 query 键 → .ignore(web 形状精确只有一个 join)")
|
||||
func extraQueryKeysIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
|
||||
) == .ignore
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("非空 path → .ignore")
|
||||
func nonEmptyPathIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("带 fragment → .ignore")
|
||||
func fragmentIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("带 userinfo(二维码钓鱼形状)→ .ignore")
|
||||
func userInfoIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("无 host → .ignore")
|
||||
func missingHostIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
|
||||
== .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("自定义 scheme 分支零回归:webterminal 仍需 host+join 双 v4")
|
||||
func customSchemeBranchUnchanged() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
|
||||
== .ignore
|
||||
)
|
||||
let hostId = UUID()
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
|
||||
)
|
||||
) == .openSession(hostId: hostId, sessionId: sessionId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// C 组 —— `DeepLinkHandler` 侧:origin → 已配对主机的解析。
|
||||
@MainActor
|
||||
@Suite("DeepLinkJoinHandler")
|
||||
struct DeepLinkJoinHandlerTests {
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
|
||||
@MainActor
|
||||
private final class ActionRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
private(set) var pairingShownCount = 0
|
||||
|
||||
var actions: DeepLinkHandler.Actions {
|
||||
DeepLinkHandler.Actions(
|
||||
openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
},
|
||||
showPairing: { [weak self] in self?.pairingShownCount += 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeHost(_ base: String) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
private static func shareURL(_ origin: String) throws -> URL {
|
||||
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
|
||||
}
|
||||
|
||||
@Test("origin 命中已配对主机 → 恰一次 openSession,无 hint")
|
||||
func knownOriginOpensSession() async throws {
|
||||
let host = try Self.makeHost("http://192.168.1.5:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.host == host)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
|
||||
func unknownOriginNeverPairsSilently() async throws {
|
||||
let paired = try Self.makeHost("http://192.168.1.5:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||||
}
|
||||
|
||||
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
|
||||
func hintNeverEchoesLinkContents() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
|
||||
|
||||
let hint = try #require(handler.hintMessage)
|
||||
#expect(!hint.contains("evil.example"))
|
||||
#expect(!hint.contains(Self.validSessionId))
|
||||
}
|
||||
|
||||
@Test("origin 比对经 originHeader:大小写/尾斜杠同一主机,端口不同则不是")
|
||||
func originComparisonUsesOriginHeader() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
|
||||
#expect(recorder.opened.count == 1)
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
|
||||
#expect(recorder.opened.count == 1) // 端口不同 → 不是同一主机
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
}
|
||||
|
||||
@Test("scheme 不同 → 不是同一主机")
|
||||
func schemeMismatchIsDifferentHost() async throws {
|
||||
let host = try Self.makeHost("http://mac.local")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("https://mac.local"))
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
}
|
||||
|
||||
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
|
||||
func coldLaunchStashAppliesJoin() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
|
||||
#expect(recorder.opened.isEmpty)
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
}
|
||||
|
||||
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
|
||||
func invalidWebLinkCountedNeverStashed() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(
|
||||
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
|
||||
)
|
||||
#expect(handler.ignoredCount == 1)
|
||||
|
||||
await handler.markReady()
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
}
|
||||
}
|
||||
@@ -78,14 +78,30 @@ struct DeepLinkRouterTests {
|
||||
|
||||
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
||||
|
||||
@Test("scheme 非 webterminal → .ignore")
|
||||
func wrongSchemeIgnored() throws {
|
||||
/// T-iOS-35 **有意改写**:本例原名"scheme 非 webterminal → .ignore",断言
|
||||
/// http(s) 一律被拒。web 分享 QR 互通落地后 http(s) 有了自己的白名单形状
|
||||
/// (`<origin>/?join=<uuid>`,见 `DeepLinkJoinTests`),所以那条断言的**理由**
|
||||
/// 变了:这里的 URL 仍 `.ignore`,但原因是它带了 `host=` 这个多余 query 键
|
||||
/// (web 形状精确只允许单一 `join` 键),而不是"scheme 不对"。
|
||||
/// scheme 白名单本身改由下一例(非 http/https/webterminal)继续钉住。
|
||||
@Test("https 但不是 web 分享形状(多余 query 键)→ .ignore")
|
||||
func httpsWithExtraQueryKeysIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("白名单外的 scheme(ftp/file/javascript)→ .ignore")
|
||||
func nonWhitelistedSchemeIgnored() throws {
|
||||
for scheme in ["ftp", "file", "javascript"] {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore, "\(scheme) 不该被接受")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("action 非 open → .ignore")
|
||||
func wrongActionIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
|
||||
@@ -44,11 +44,21 @@ struct DesignSystemTests {
|
||||
#expect(!approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("semantic status colors match the desktop/web status palette")
|
||||
/// T-iOS-34 **有意改写**:这三个 token 从"单值"变成了 scheme-adaptive
|
||||
/// (深色 = web 原值不变,浅色 = 新增的可读变体,见 `Tokens.swift`)。原用例
|
||||
/// 用 `UIColor(color).getRed(...)` 隐式按**测试进程当前 trait**(浅色)解析,
|
||||
/// 于是量到了新的浅色值。断言的意图没变 —— "深色档必须逐字节等于桌面 web 的
|
||||
/// 状态色" —— 只是现在必须**显式指定深色 trait** 才能表达这个意图。
|
||||
/// 浅色档的取值与对比度门槛由 `AppThemeTests` 覆盖。
|
||||
@Test("semantic status colors match the desktop/web status palette (dark scheme)")
|
||||
func statusColorsMatchDirection() {
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||||
let dark = UITraitCollection(userInterfaceStyle: .dark)
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, in: dark,
|
||||
r: 70, g: 208, b: 127) // #46D07F web --green
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, in: dark,
|
||||
r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, in: dark,
|
||||
r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||||
}
|
||||
|
||||
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||||
@@ -150,6 +160,13 @@ struct DesignSystemTests {
|
||||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
/// Same, but resolving an adaptive token in an explicit scheme.
|
||||
private func assertColor(
|
||||
_ color: Color, in traits: UITraitCollection, r: Int, g: Int, b: Int
|
||||
) {
|
||||
assertRGBA(rgba(UIColor(color).resolvedColor(with: traits)), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
|
||||
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
|
||||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||||
|
||||
177
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal file
177
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-34 · Dynamic Type 不破版(doc §4 F 组,条目 25–29)。
|
||||
///
|
||||
/// 验收口径是"最大字号不破版",所以断言必须是**量出来的**,不是"看着还行":
|
||||
/// 每个受测视图套 `UIHostingController` 在一个 320pt 宽(iPhone SE / 最窄
|
||||
/// 现役机型)的提议里量 `sizeThatFits`,再按两条规则判定:
|
||||
/// 1. **不横向溢出** —— 返回宽度 ≤ 提议宽度(超出即会被裁/顶出屏幕);
|
||||
/// 2. **长高而不是被裁** —— AX 档高度必须 > 标准档高度(固定高容器会让它相等)。
|
||||
///
|
||||
/// 密集数字(`TelemetryChip` / `dsMetaText`)另走**夹取**策略:表格数字放大到
|
||||
/// AX5 会把一行元信息撑成三行,故 DS 在 `DS.Typography.numericClamp` 处统一封顶,
|
||||
/// 测试用"AX2 与 AX5 量出同一高度"来钉死夹取真的生效(而不是只写了个注释)。
|
||||
@MainActor
|
||||
@Suite("DynamicTypeLayout")
|
||||
struct DynamicTypeLayoutTests {
|
||||
|
||||
// MARK: - F25 · GateBanner(最关键的一屏:批准/拒绝 shell 命令)
|
||||
|
||||
@Test("GateBanner 在 AX5 下不横向溢出,且相比标准档是长高而非被裁")
|
||||
func gateBannerSurvivesLargestType() {
|
||||
let gate = GateState(kind: .tool, detail: "Bash(rm -rf ./build)", epoch: 1)
|
||||
let banner = GateBanner(gate: gate) { _, _ in }
|
||||
|
||||
let standard = LayoutProbe.fit(banner, size: .large)
|
||||
let largest = LayoutProbe.fit(banner, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(largest.height.isFinite)
|
||||
#expect(largest.height > standard.height)
|
||||
}
|
||||
|
||||
// MARK: - F26/F27 · 密集数字:夹取生效
|
||||
|
||||
@Test("numericClamp 策略:封顶在 accessibility2,严格小于 accessibility5")
|
||||
func numericClampPolicy() {
|
||||
#expect(DS.Typography.numericClamp == .accessibility2)
|
||||
#expect(DS.Typography.numericClamp < .accessibility5)
|
||||
}
|
||||
|
||||
@Test("TelemetryChip:AX2 与 AX5 同高(夹取生效),且不横向溢出")
|
||||
func telemetryChipIsClamped() {
|
||||
let chip = TelemetryChip(systemImage: "cpu", text: "ctx 92% · $0.1234")
|
||||
|
||||
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||
let largest = LayoutProbe.fit(chip, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||
}
|
||||
|
||||
@Test("TelemetryChip 在夹取上限之前照常放大(不是把字号焊死)")
|
||||
func telemetryChipStillScalesBelowClamp() {
|
||||
let chip = TelemetryChip(text: "161×50")
|
||||
|
||||
let standard = LayoutProbe.fit(chip, size: .large)
|
||||
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||
|
||||
#expect(clampEdge.height > standard.height)
|
||||
}
|
||||
|
||||
@Test("dsMetaText 元信息行走同一夹取(单一出处,非逐屏各写一遍)")
|
||||
func metaTextIsClamped() {
|
||||
let meta = Text(verbatim: "2 台设备在看 · 161×50").dsMetaText()
|
||||
|
||||
let clampEdge = LayoutProbe.fit(meta, size: DS.Typography.numericClamp)
|
||||
let largest = LayoutProbe.fit(meta, size: .accessibility5)
|
||||
|
||||
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||
}
|
||||
|
||||
// MARK: - F28 · DSButtonStyle(gate 的两个半宽按钮是最窄的按钮场景)
|
||||
|
||||
@Test("DSButtonStyle 在 AX5 半宽容器里仍 ≥ 44pt 高且不横向溢出")
|
||||
func buttonStyleSurvivesLargestType() {
|
||||
let button = Button(GateChoiceSpec.label(for: .approve)) {}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
let halfWidth = LayoutProbe.phoneWidth / 2
|
||||
|
||||
let standard = LayoutProbe.fit(button, width: halfWidth, size: .large)
|
||||
let largest = LayoutProbe.fit(button, width: halfWidth, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= halfWidth + LayoutProbe.epsilon)
|
||||
#expect(largest.height >= DS.Layout.minHitTarget)
|
||||
// `minHeight`(而非 `height`)才让换行后的标签长高而不是被裁到 44pt。
|
||||
#expect(largest.height > standard.height)
|
||||
}
|
||||
|
||||
// MARK: - 新增设置页自身也过一遍 AX5
|
||||
|
||||
@Test("SettingsScreen 在 AX5 下可渲染且不横向溢出")
|
||||
func settingsScreenSurvivesLargestType() {
|
||||
let screen = SettingsScreen(themeStore: ThemeStore(defaults: FakeThemeDefaults()))
|
||||
|
||||
let size = LayoutProbe.fit(NavigationStack { screen }, size: .accessibility5)
|
||||
|
||||
#expect(size.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(size.height.isFinite)
|
||||
}
|
||||
|
||||
// MARK: - F29 · KeyBar(UIKit,固定条高 vs 放大的键帽)—— 已知缺口
|
||||
|
||||
/// 量到的事实(iPhone 16 Pro 模拟器):AX5 下一个键帽需要
|
||||
/// **108.24pt**(footnote 行高 52.51 + caption2 行高 47.73 + 上下 4+4 内衬),
|
||||
/// 而 `KeyBarMetrics.barHeight` 是**固定 52pt**(44 + 8)——超过一半被裁。
|
||||
///
|
||||
/// 修法(一行):`barHeight` 改为随 Dynamic Type 缩放,例如
|
||||
/// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`,
|
||||
/// 并让 `intrinsicContentSize` 用同一个值。
|
||||
/// **`Components/KeyBar.swift` 不在 C4 的 Owns 里**,故这里只用
|
||||
/// `withKnownIssue` 把缺口钉成机器可见的记录:修好之后本用例会报
|
||||
/// "known issue was not recorded",提醒把这个标记删掉。
|
||||
@Test("KeyBar 键帽在 AX5 下超出固定条高(已知缺口:KeyBar.swift 属他人 Owns)")
|
||||
func keyBarKeycapExceedsBarHeightAtLargestType() {
|
||||
withKnownIssue("KeyBarMetrics.barHeight 固定 52pt,AX5 键帽需约 108pt → 裁切") {
|
||||
let requirement = KeyBarProbe.largestTypeRequirement()
|
||||
#expect(
|
||||
requirement.needed <= requirement.barHeight,
|
||||
"AX5 键帽需 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Probes
|
||||
|
||||
/// SwiftUI 度量:把视图放进 `UIHostingController`,在给定宽度的提议下问它
|
||||
/// `sizeThatFits`。不套 `.frame(width:)` —— 那会把返回宽度强行钉成提议宽度,
|
||||
/// 正好掩盖我们要抓的横向溢出。
|
||||
@MainActor
|
||||
enum LayoutProbe {
|
||||
/// 最窄现役 iPhone 逻辑宽度(SE 系)。
|
||||
static let phoneWidth: CGFloat = 320
|
||||
/// 浮点度量容差。
|
||||
static let epsilon: CGFloat = 0.5
|
||||
|
||||
static func fit<V: View>(
|
||||
_ view: V, width: CGFloat = phoneWidth, size: DynamicTypeSize
|
||||
) -> CGSize {
|
||||
let host = UIHostingController(rootView: view.dynamicTypeSize(size))
|
||||
host.view.layoutIfNeeded()
|
||||
return host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit 度量:键帽在 AX5 下**需要**多高,对比键栏**给**多高。
|
||||
///
|
||||
/// 为什么不直接把 `KeyBarView` 构建在 AX5 trait 里量:`KeyBarView` 的字号来自
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(无 `compatibleWith:`),它读的是
|
||||
/// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current`
|
||||
/// —— 所以 `performAsCurrent { KeyBarView() }` 量出来的是默认字号(实测 38pt),
|
||||
/// 是个会"永远通过"的假绿。改为按 `compatibleWith:` 取真实 AX5 行高来算需求,
|
||||
/// 与键帽的两行(glyph + caption)+ 上下内衬一致。
|
||||
@MainActor
|
||||
enum KeyBarProbe {
|
||||
/// AX5 内容尺寸类别。
|
||||
private static let largestCategory = UIContentSizeCategory
|
||||
.accessibilityExtraExtraExtraLarge
|
||||
|
||||
/// - Returns: (键栏给的高度, AX5 下键帽两行所需高度)
|
||||
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
|
||||
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
|
||||
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
|
||||
// KeyBarMetrics.buttonInsets 的上下各 xs4。
|
||||
let verticalInsets = DS.Space.xs4 * 2
|
||||
return (
|
||||
barHeight: KeyBarView().intrinsicContentSize.height,
|
||||
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
|
||||
)
|
||||
}
|
||||
}
|
||||
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal file
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// C2 · 纯呈现层(同步带 / 未推送边界 / 相对时间 / 写失败文案)。
|
||||
///
|
||||
/// 真源 `docs/plans/w6-project-git-panel.md` 与 web 参照实现
|
||||
/// `public/projects.ts:615-745 makeSyncBand` —— 本套用例逐条钉住那份"只有一种
|
||||
/// 状态可以显示为绿色"的规则(RED 清单 36–47,见 docs/plans/ios-completion.md §4)。
|
||||
@Suite("GitPanelPresentation")
|
||||
struct GitPanelPresentationTests {
|
||||
/// 固定"现在":2026-07-30T12:00:00Z 的毫秒值,避免用例依赖真实时钟。
|
||||
private static let nowMs: Double = 1_785_412_800_000
|
||||
|
||||
private static func minutesAgo(_ minutes: Double) -> Double {
|
||||
nowMs - minutes * 60 * 1000
|
||||
}
|
||||
|
||||
// MARK: - 同步带(RED 36–43)
|
||||
|
||||
@Test("非 git 目录(sync == nil)→ 无同步带")
|
||||
func nonGitHasNoBand() {
|
||||
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
|
||||
}
|
||||
|
||||
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
|
||||
func inSyncNeedsFreshFetch() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(5)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.isInSync)
|
||||
#expect(band.isBehindVerified)
|
||||
#expect(band.canFetch)
|
||||
#expect(band.head == .tracking(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0
|
||||
))
|
||||
}
|
||||
|
||||
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS)→ 不绿,↓ 未核实")
|
||||
func staleFetchIsNeverGreen() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(61)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(!band.isInSync)
|
||||
#expect(!band.isBehindVerified)
|
||||
}
|
||||
|
||||
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615)")
|
||||
func staleThresholdMatchesWeb() {
|
||||
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
@Test("从未 fetch(lastFetchMs == nil)→ 视为过期,不绿")
|
||||
func neverFetchedIsStale() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(!band.isInSync)
|
||||
#expect(!band.isBehindVerified)
|
||||
}
|
||||
|
||||
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug)")
|
||||
func noUpstreamIsNeverGreen() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .noUpstream)
|
||||
#expect(!band.isInSync)
|
||||
#expect(band.canFetch)
|
||||
}
|
||||
|
||||
@Test("分离 HEAD → 「分离 HEAD」,fetch 禁用,无 ↑↓")
|
||||
func detachedDisablesFetch() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
|
||||
dirtyCount: nil, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .detached)
|
||||
#expect(!band.canFetch)
|
||||
#expect(!band.isInSync)
|
||||
}
|
||||
|
||||
@Test("ahead 读不到(nil)→ 保留 nil(渲染 ↑ —),且不绿")
|
||||
func unknownAheadIsNotZero() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: nil, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(1)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
|
||||
#expect(!band.isInSync)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"dirtyCount:nil → 未检查(不是 clean)、0 → clean、3 → changed(3)",
|
||||
arguments: [
|
||||
(Int?.none, GitSyncBand.Dirty.unknown),
|
||||
(Int?(0), GitSyncBand.Dirty.clean),
|
||||
(Int?(3), GitSyncBand.Dirty.changed(3)),
|
||||
] as [(Int?, GitSyncBand.Dirty)]
|
||||
)
|
||||
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
|
||||
dirtyCount: dirtyCount, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.dirty == expected)
|
||||
}
|
||||
|
||||
// MARK: - 未推送边界(RED 44–46)
|
||||
|
||||
@Test("边界画在最后一个 unpushed 之后,且只画一次")
|
||||
func boundaryAfterLastUnpushed() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
|
||||
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
|
||||
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||
}
|
||||
|
||||
@Test("unpushed 只在严格 true 时生效(nil ≠ false ≠ true)")
|
||||
func nilUnpushedDrawsNoBoundary() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
|
||||
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
|
||||
}
|
||||
|
||||
@Test("无上游 → 完全不画边界(没有可比对的东西)")
|
||||
func noUpstreamDrawsNoBoundary() {
|
||||
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
|
||||
}
|
||||
|
||||
@Test("未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后")
|
||||
func interleavedUnpushedStillBounds() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
|
||||
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
|
||||
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||
}
|
||||
|
||||
// MARK: - 相对时间
|
||||
|
||||
@Test(
|
||||
"相对时间:秒/分/时/天各档非空且互不相同",
|
||||
arguments: [0.5, 5, 90, 60 * 26] as [Double]
|
||||
)
|
||||
func relativeTimeBuckets(minutes: Double) {
|
||||
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
|
||||
|
||||
#expect(!label.isEmpty)
|
||||
}
|
||||
|
||||
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
|
||||
func futureTimestampDegrades() {
|
||||
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
|
||||
|
||||
#expect(!label.contains("-"))
|
||||
}
|
||||
|
||||
// MARK: - 写失败文案(RED 47)
|
||||
|
||||
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
|
||||
func serverMessageIsSurfacedVerbatim() {
|
||||
let text = GitWriteFeedback.message(
|
||||
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
|
||||
)
|
||||
|
||||
#expect(text.contains("Nothing staged to commit."))
|
||||
}
|
||||
|
||||
@Test("服务器没给 message → 兜底中文文案(非空)")
|
||||
func missingServerMessageFallsBack() {
|
||||
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
|
||||
|
||||
#expect(!text.isEmpty)
|
||||
#expect(text.contains("提交失败"))
|
||||
}
|
||||
|
||||
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
|
||||
func thrownAPIErrorUsesItsOwnCopy() {
|
||||
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
|
||||
|
||||
#expect(text == APIClientError.unauthorized.message)
|
||||
}
|
||||
|
||||
@Test("非 APIClientError(传输层)→ 兜底文案,不 crash")
|
||||
func transportErrorFallsBack() {
|
||||
let text = GitWriteFeedback.message(
|
||||
for: URLError(.notConnectedToInternet), fallback: "推送失败"
|
||||
)
|
||||
|
||||
#expect(text.contains("推送失败"))
|
||||
}
|
||||
}
|
||||
328
ios/App/WebTermTests/GitPanelViewModelTests.swift
Normal file
328
ios/App/WebTermTests/GitPanelViewModelTests.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// C2 · git 面板 VM(RED 清单 47–51 + 分区独立降级)。
|
||||
///
|
||||
/// 依赖全部以闭包注入(与 ProjectDetailViewModel/DiffViewModel 同一纪律)——
|
||||
/// 路由构建/状态码映射已在 APIClient 包内测过,本套只测 VM 归约:
|
||||
/// 服务器安全文案原样上浮、写操作忙态去重、每个分区独立失败不拖垮整屏。
|
||||
@MainActor
|
||||
@Suite("GitPanelViewModel")
|
||||
struct GitPanelViewModelTests {
|
||||
private nonisolated static let path = "/repos/web-terminal"
|
||||
private nonisolated static let nowMs: Double = 1_785_412_800_000
|
||||
|
||||
private nonisolated static func detail(
|
||||
sync: SyncState? = SyncState(
|
||||
upstream: "origin/develop", ahead: 2, behind: 0, lastFetchMs: 1_785_412_500_000
|
||||
),
|
||||
dirtyCount: Int? = 3
|
||||
) -> ProjectDetail {
|
||||
ProjectDetail(
|
||||
name: "web-terminal", path: path, isGit: true, branch: "develop", dirty: true,
|
||||
worktrees: [], sessions: [], hasClaudeMd: false, claudeMd: nil,
|
||||
dirtyCount: dirtyCount, sync: sync
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 加载与分区降级
|
||||
|
||||
@Test("load 成功 → 同步带/分支/日志/改动列表就位")
|
||||
func loadPopulatesEverySection() async {
|
||||
let vm = Self.makeViewModel(
|
||||
log: { GitLogResult(commits: [
|
||||
CommitLogEntry(hash: "abc1234", at: 1_785_412_000_000, subject: "feat: x", unpushed: true),
|
||||
], truncated: false, upstream: "origin/develop") },
|
||||
changes: { staged in
|
||||
staged ? [] : [Self.changedFile("src/a.ts")]
|
||||
}
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.isLoaded)
|
||||
#expect(vm.branch == "develop")
|
||||
#expect(vm.band?.head == .tracking(upstream: "origin/develop", ahead: 2, behind: 0))
|
||||
#expect(vm.band?.dirty == .changed(3))
|
||||
#expect(vm.log?.commits.count == 1)
|
||||
#expect(vm.unstaged.map(\.displayPath) == ["src/a.ts"])
|
||||
#expect(vm.staged.isEmpty)
|
||||
}
|
||||
|
||||
@Test("日志失败不拖垮面板:其余分区照常,日志区显示自己的错误")
|
||||
func logFailureIsContained() async {
|
||||
let vm = Self.makeViewModel(log: { throw APIClientError.gitDataUnavailable })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.isLoaded)
|
||||
#expect(vm.band != nil)
|
||||
#expect(vm.logErrorMessage == APIClientError.gitDataUnavailable.message)
|
||||
}
|
||||
|
||||
@Test("详情失败 → 状态区错误 + 无同步带(绝不编造 ↑↓)")
|
||||
func detailFailureLeavesNoBand() async {
|
||||
let vm = Self.makeViewModel(detail: { throw APIClientError.projectNotFound })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.band == nil)
|
||||
#expect(vm.stateErrorMessage == APIClientError.projectNotFound.message)
|
||||
}
|
||||
|
||||
@Test("PR 单独加载(gh 是一次外网调用,不阻塞面板首屏)")
|
||||
func prLoadsSeparately() async {
|
||||
let vm = Self.makeViewModel(pr: { PrStatus(availability: .ok, number: 7, title: "t") })
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.pr == nil)
|
||||
|
||||
await vm.loadPullRequest()
|
||||
#expect(vm.pr?.number == 7)
|
||||
}
|
||||
|
||||
@Test("gh 未安装/未登录是 200 + 非 ok availability,不是错误")
|
||||
func degradedGhIsNotAnError() async {
|
||||
let vm = Self.makeViewModel(pr: { PrStatus(availability: .notInstalled) })
|
||||
|
||||
await vm.loadPullRequest()
|
||||
|
||||
#expect(vm.pr?.availability == .notInstalled)
|
||||
#expect(vm.errorMessage == nil)
|
||||
}
|
||||
|
||||
// MARK: - 写操作文案(47–49)
|
||||
|
||||
@Test("stage 被拒 → 服务器安全文案原样显示")
|
||||
func stageRejectionSurfacesServerMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
stage: { _, _ in .rejected(status: 403, message: "Git operations are disabled.") }
|
||||
)
|
||||
|
||||
await vm.setStaged(Self.changedFile("src/a.ts"), staged: true)
|
||||
|
||||
#expect(vm.errorMessage == "Git operations are disabled.")
|
||||
}
|
||||
|
||||
@Test("commit 成功 → 清空输入框 + 短 sha 提示")
|
||||
func commitSuccessClearsDraft() async {
|
||||
let vm = Self.makeViewModel(commit: { _ in .ok(CommitResult(commit: "abc1234")) })
|
||||
vm.commitMessage = "fix: 修一下"
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(vm.commitMessage.isEmpty)
|
||||
#expect(vm.noticeMessage?.contains("abc1234") == true)
|
||||
#expect(vm.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test("commit 409(无暂存)→ 保留输入框内容,显示服务器文案")
|
||||
func commitConflictKeepsDraft() async {
|
||||
let vm = Self.makeViewModel(
|
||||
commit: { _ in .rejected(status: 409, message: "Nothing staged to commit.") }
|
||||
)
|
||||
vm.commitMessage = "fix: 修一下"
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(vm.commitMessage == "fix: 修一下")
|
||||
#expect(vm.errorMessage == "Nothing staged to commit.")
|
||||
}
|
||||
|
||||
@Test("空提交信息 → 一次请求都不发")
|
||||
func blankCommitMessageSkipsNetwork() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.commitMessage = " "
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(await spy.commitCalls == 0)
|
||||
#expect(vm.errorMessage == GitPanelCopy.commitMessageRequired)
|
||||
}
|
||||
|
||||
@Test("push 401 是主机 git 凭据问题 → 用服务器文案,不与访问令牌 401 混淆")
|
||||
func pushAuthIsNotTheAccessTokenGate() async {
|
||||
let vm = Self.makeViewModel(
|
||||
push: { .rejected(status: 401, message: "Push authentication required on the host.") }
|
||||
)
|
||||
|
||||
await vm.push()
|
||||
|
||||
#expect(vm.errorMessage == "Push authentication required on the host.")
|
||||
#expect(vm.errorMessage != APIClientError.unauthorized.message)
|
||||
}
|
||||
|
||||
@Test("429 → 限流文案,且不自动重试")
|
||||
func rateLimitedIsNotRetried() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, push: { .rateLimited })
|
||||
|
||||
await vm.push()
|
||||
|
||||
#expect(await spy.pushCalls == 1)
|
||||
#expect(vm.errorMessage == GitWriteFeedback.rateLimited)
|
||||
}
|
||||
|
||||
@Test("fetch 成功 → 重新读取详情(lastFetchMs 由服务器给,客户端绝不自己往前拨)")
|
||||
func fetchReloadsStateFromServer() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, fetchRemote: { .ok(FetchResult(remote: "origin", lastFetchMs: Self.nowMs)) })
|
||||
|
||||
await vm.load()
|
||||
let loadsAfterFirstLoad = await spy.detailCalls
|
||||
await vm.fetchRemote()
|
||||
|
||||
#expect(await spy.detailCalls == loadsAfterFirstLoad + 1)
|
||||
}
|
||||
|
||||
// MARK: - 忙态去重(50)
|
||||
|
||||
@Test("写操作进行中重复点击 → 只发一次请求")
|
||||
func busyDeduplicatesWrites() async {
|
||||
let gate = GitPanelGate()
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
push: {
|
||||
await gate.wait()
|
||||
return .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
}
|
||||
)
|
||||
|
||||
let first = Task { await vm.push() }
|
||||
await Self.spin(until: { vm.busy == .push })
|
||||
await vm.push()
|
||||
#expect(await spy.pushCalls == 1)
|
||||
|
||||
await gate.release()
|
||||
await first.value
|
||||
#expect(vm.busy == nil)
|
||||
}
|
||||
|
||||
// MARK: - 重命名文件的暂存路径(51)
|
||||
|
||||
@Test("重命名 → 同时送 oldPath 与 newPath(否则删除侧不会被暂存)")
|
||||
func renameStagesBothPaths() {
|
||||
let file = DiffFile(
|
||||
oldPath: "src/old.ts", newPath: "src/new.ts", status: .renamed,
|
||||
added: 1, removed: 1, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let changed = GitPanelViewModel.ChangedFile.make(from: file)
|
||||
|
||||
#expect(changed.stagePaths == ["src/old.ts", "src/new.ts"])
|
||||
#expect(changed.displayPath.contains("src/new.ts"))
|
||||
}
|
||||
|
||||
@Test("普通修改 → 只送 newPath")
|
||||
func modifiedStagesOnePath() {
|
||||
let file = DiffFile(
|
||||
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
|
||||
added: 2, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let changed = GitPanelViewModel.ChangedFile.make(from: file)
|
||||
|
||||
#expect(changed.stagePaths == ["src/a.ts"])
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private nonisolated static func changedFile(_ path: String) -> GitPanelViewModel.ChangedFile {
|
||||
GitPanelViewModel.ChangedFile(
|
||||
displayPath: path, stagePaths: [path], status: .modified, added: 1, removed: 0
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeViewModel(
|
||||
spy: GitPanelSpy = GitPanelSpy(),
|
||||
detail: (@Sendable () async throws -> ProjectDetail)? = nil,
|
||||
log: (@Sendable () async throws -> GitLogResult)? = nil,
|
||||
pr: (@Sendable () async throws -> PrStatus)? = nil,
|
||||
changes: (@Sendable (Bool) async throws -> [GitPanelViewModel.ChangedFile])? = nil,
|
||||
stage: (@Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>)? = nil,
|
||||
commit: (@Sendable (String) async throws -> GitWriteOutcome<CommitResult>)? = nil,
|
||||
push: (@Sendable () async throws -> GitWriteOutcome<PushResult>)? = nil,
|
||||
fetchRemote: (@Sendable () async throws -> GitWriteOutcome<FetchResult>)? = nil
|
||||
) -> GitPanelViewModel {
|
||||
GitPanelViewModel(
|
||||
path: path,
|
||||
dependencies: GitPanelViewModel.Dependencies(
|
||||
detail: {
|
||||
await spy.recordDetail()
|
||||
if let detail { return try await detail() }
|
||||
return Self.detail()
|
||||
},
|
||||
log: {
|
||||
if let log { return try await log() }
|
||||
return GitLogResult(commits: [], truncated: false, upstream: "origin/develop")
|
||||
},
|
||||
pr: {
|
||||
if let pr { return try await pr() }
|
||||
return PrStatus(availability: .noPr)
|
||||
},
|
||||
changes: { staged in
|
||||
if let changes { return try await changes(staged) }
|
||||
return []
|
||||
},
|
||||
stage: { files, isStaging in
|
||||
await spy.recordStage()
|
||||
if let stage { return try await stage(files, isStaging) }
|
||||
return .ok(StageResult(staged: isStaging, count: files.count))
|
||||
},
|
||||
commit: { message in
|
||||
await spy.recordCommit()
|
||||
if let commit { return try await commit(message) }
|
||||
return .ok(CommitResult(commit: "abc1234"))
|
||||
},
|
||||
push: {
|
||||
await spy.recordPush()
|
||||
if let push { return try await push() }
|
||||
return .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
},
|
||||
fetchRemote: {
|
||||
if let fetchRemote { return try await fetchRemote() }
|
||||
return .ok(FetchResult(remote: "origin", lastFetchMs: nowMs))
|
||||
},
|
||||
nowMs: { nowMs }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func spin(
|
||||
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
|
||||
) async {
|
||||
for _ in 0..<maxYields where !condition() {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor GitPanelSpy {
|
||||
private(set) var detailCalls = 0
|
||||
private(set) var stageCalls = 0
|
||||
private(set) var commitCalls = 0
|
||||
private(set) var pushCalls = 0
|
||||
|
||||
func recordDetail() { detailCalls += 1 }
|
||||
func recordStage() { stageCalls += 1 }
|
||||
func recordCommit() { commitCalls += 1 }
|
||||
func recordPush() { pushCalls += 1 }
|
||||
}
|
||||
|
||||
private actor GitPanelGate {
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let pending = waiters
|
||||
waiters = []
|
||||
pending.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · 移除主机 — the path `PushRegistrar.handleHostRemoved` never had.
|
||||
///
|
||||
/// Two properties matter beyond "the row disappears":
|
||||
/// 1. the APNs de-registration actually happens, and happens BEFORE the record
|
||||
/// is deleted (the request needs the host's endpoint AND its access token,
|
||||
/// which live in that record);
|
||||
/// 2. no secret survives: the token is a field of the removed record, so the
|
||||
/// same write that drops the host drops the token.
|
||||
@MainActor
|
||||
@Suite("Host removal")
|
||||
struct HostRemovalTests {
|
||||
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
/// Records the interleaving of the push hook and the store write.
|
||||
private actor Journal {
|
||||
enum Entry: Equatable {
|
||||
case unregistered(UUID)
|
||||
case removed(UUID)
|
||||
}
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
|
||||
func record(_ entry: Entry) {
|
||||
entries = entries + [entry]
|
||||
}
|
||||
}
|
||||
|
||||
/// `InMemoryHostStore` + journalling of `remove`, so ordering is observable.
|
||||
private actor JournallingStore: HostStore {
|
||||
private let base = InMemoryHostStore()
|
||||
private let journal: Journal
|
||||
private let removeFails: Bool
|
||||
|
||||
init(journal: Journal, removeFails: Bool = false) {
|
||||
self.journal = journal
|
||||
self.removeFails = removeFails
|
||||
}
|
||||
|
||||
func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() }
|
||||
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
try await base.upsert(host)
|
||||
}
|
||||
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] {
|
||||
await journal.record(.removed(id))
|
||||
if removeFails { throw StoreFailure() }
|
||||
return try await base.remove(id: id)
|
||||
}
|
||||
|
||||
func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try await base.accessToken(host: id)
|
||||
}
|
||||
|
||||
func setAccessToken(
|
||||
_ token: AccessToken?, host id: UUID
|
||||
) async throws -> [HostRegistry.Host] {
|
||||
try await base.setAccessToken(token, host: id)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
|
||||
private actor ThrowingHostStore: HostStore {
|
||||
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
throw StoreFailure()
|
||||
}
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
}
|
||||
|
||||
private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel {
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, _ in .success(endpoint) },
|
||||
unregisterPush: { host in await journal.record(.unregistered(host.id)) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeHost(
|
||||
name: String, port: Int, token: String? = nil
|
||||
) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.5:\(port)"))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: name,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The wiring
|
||||
|
||||
@Test("移除主机:先注销该主机上的推送 token,再删记录(顺序是硬要求)")
|
||||
func removalDeregistersPushBeforeDeletingTheRecord() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(await journal.entries == [.unregistered(host.id), .removed(host.id)])
|
||||
#expect(viewModel.pairedHosts.isEmpty)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
}
|
||||
|
||||
@Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)")
|
||||
func removalLeavesNoTokenBehind() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
|
||||
_ = try await store.upsert(host)
|
||||
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
#expect(try await store.loadAll().allSatisfy { $0.accessToken == nil })
|
||||
}
|
||||
|
||||
@Test("只移除指定主机,其它主机及其令牌不受影响")
|
||||
func removalIsScopedToOneHost() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken)
|
||||
let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken)
|
||||
_ = try await store.upsert(doomed)
|
||||
_ = try await store.upsert(kept)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: doomed.id)
|
||||
|
||||
#expect(viewModel.pairedHosts.map(\.id) == [kept.id])
|
||||
#expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken)
|
||||
#expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)])
|
||||
}
|
||||
|
||||
@Test("未知 id → 完全 no-op(不注销、不写存储)")
|
||||
func unknownIdIsANoOp() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: UUID())
|
||||
|
||||
#expect(await journal.entries.isEmpty)
|
||||
#expect(try await store.loadAll().count == 1)
|
||||
}
|
||||
|
||||
@Test("存储写入失败 → 显式报错(不假装移除成功)")
|
||||
func storeFailureIsSurfaced() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal, removeFails: true)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed)
|
||||
#expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged
|
||||
}
|
||||
|
||||
// MARK: - Manage-section loading
|
||||
|
||||
@Test("加载已配对主机:成功清错,失败给出显式话术")
|
||||
func loadingPairedHostsSurfacesErrors() async throws {
|
||||
let journal = Journal()
|
||||
let failing = makeViewModel(store: ThrowingHostStore(), journal: journal)
|
||||
|
||||
await failing.loadPairedHosts()
|
||||
|
||||
#expect(failing.pairedHosts.isEmpty)
|
||||
#expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed)
|
||||
|
||||
let working = makeViewModel(store: InMemoryHostStore(), journal: journal)
|
||||
await working.loadPairedHosts()
|
||||
#expect(working.hostsErrorMessage == nil)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ struct NewSessionInCwdTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The pairing probe against a token-gated host, driven through the REAL
|
||||
/// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`.
|
||||
///
|
||||
/// Two things are pinned:
|
||||
/// 1. a 401 is no longer misreported as "Origin rejected" — with the token gate
|
||||
/// live, 401 has TWO causes and one status code, so the copy must name both;
|
||||
/// 2. the candidate token really travels: `Cookie: webterm_auth=<t>` on every
|
||||
/// HTTP leg (RO GET and guarded DELETE alike, §1.1 — the cookie is orthogonal
|
||||
/// to `Origin`, which only the DELETE carries).
|
||||
///
|
||||
/// These live in the App test target because APIClient's own test suite is B1's
|
||||
/// file ownership; the probe function under test is the package's public one.
|
||||
@Suite("Pairing probe · 401 and the token cookie")
|
||||
struct PairingProbeUnauthorizedTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||||
|
||||
private func endpoint() throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: Self.base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
/// Script the whole happy path: RO list → WS attach → guarded kill.
|
||||
private func scriptHappyPath(
|
||||
http: FakeHTTPTransport, ws: FakeTransport
|
||||
) async throws {
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE",
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||||
status: 204
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 401 → both remedies
|
||||
|
||||
@Test("RO 探针收到 401 → 不是「不是 web-terminal」,而是给出令牌+ALLOWED_ORIGINS 两条补救")
|
||||
func readOnly401NamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
status: 401,
|
||||
body: Data(#"{"error":"unauthorized"}"#.utf8)
|
||||
)
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("401"))
|
||||
#expect(hint.contains("访问令牌"))
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
// The WS leg is never reached, so no PTY is spawned on a host that
|
||||
// refuses us.
|
||||
#expect(await ws.connectAttempts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术")
|
||||
func upgradeRejectionNamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.scriptConnectFailure()
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("访问令牌"))
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
#expect(!hint.contains(":443"))
|
||||
}
|
||||
|
||||
@Test("守卫 DELETE 收到 401(而非 403)→ 同样是两因两解,不报「主机不可达」")
|
||||
func guardedDelete401NamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE",
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||||
status: 401
|
||||
)
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("访问令牌"))
|
||||
}
|
||||
|
||||
// MARK: - The candidate token actually travels
|
||||
|
||||
@Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth,且只读腿仍不带 Origin")
|
||||
func tokenTravelsOnBothHTTPLegs() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
let result = await runPairingProbe(
|
||||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||||
)
|
||||
|
||||
guard case .success = result else {
|
||||
Issue.record("expected success, got \(result)")
|
||||
return
|
||||
}
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
for request in requests {
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)",
|
||||
"every probe leg must carry the token cookie"
|
||||
)
|
||||
}
|
||||
// Origin-iff-G is untouched by the token (§1.1: orthogonal).
|
||||
let readOnly = try #require(requests.first)
|
||||
let guarded = try #require(requests.last)
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil)
|
||||
#expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base)
|
||||
}
|
||||
|
||||
@Test("没有候选令牌 → 一个 Cookie 头都不加(LAN 零配置逐字不变)")
|
||||
func noTokenMeansNoCookieHeader() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
_ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
for request in await http.recordedRequests {
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("令牌不出现在 URL 里(绝不进查询串/日志)")
|
||||
func tokenNeverAppearsInAURL() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
_ = await runPairingProbe(
|
||||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||||
)
|
||||
|
||||
for request in await http.recordedRequests {
|
||||
#expect(request.url?.absoluteString.contains(Self.token) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
430
ios/App/WebTermTests/PairingTokenViewModelTests.swift
Normal file
430
ios/App/WebTermTests/PairingTokenViewModelTests.swift
Normal file
@@ -0,0 +1,430 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · Access-token UX in the pairing flow (ios-completion §1.1).
|
||||
///
|
||||
/// Every branch of the frozen contract is pinned here, because three of the four
|
||||
/// `POST /auth` outcomes are NOT errors and must be told apart:
|
||||
/// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing)
|
||||
/// · 401 = wrong token · 429 = back off.
|
||||
@MainActor
|
||||
@Suite("Pairing access token")
|
||||
struct PairingTokenViewModelTests {
|
||||
// MARK: - Scripted probe (records what the VM asked for, in order)
|
||||
|
||||
private actor ProbeRecorder {
|
||||
/// `(endpoint, token)` per host-verification call — the token argument is
|
||||
/// what proves the candidate reaches the probe.
|
||||
private(set) var verifyCalls: [(origin: String, token: String?)] = []
|
||||
private(set) var validateCalls: [String] = []
|
||||
private var verifyResults: [Result<HostEndpoint, PairingError>]
|
||||
private var validateResults:
|
||||
[Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
|
||||
|
||||
init(
|
||||
verifyResults: [Result<HostEndpoint, PairingError>] = [],
|
||||
validateResults: [Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
|
||||
= []
|
||||
) {
|
||||
self.verifyResults = verifyResults
|
||||
self.validateResults = validateResults
|
||||
}
|
||||
|
||||
func verify(
|
||||
_ endpoint: HostEndpoint, _ token: AccessToken?
|
||||
) -> Result<HostEndpoint, PairingError> {
|
||||
verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)]
|
||||
guard let next = verifyResults.first else { return .success(endpoint) }
|
||||
verifyResults = Array(verifyResults.dropFirst())
|
||||
return next
|
||||
}
|
||||
|
||||
func validate(
|
||||
_ token: AccessToken
|
||||
) -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
|
||||
validateCalls = validateCalls + [token.rawValue]
|
||||
guard let next = validateResults.first else { return .success(.valid) }
|
||||
validateResults = Array(validateResults.dropFirst())
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
/// 32 chars from the frozen charset — shape-valid, so any rejection in a test
|
||||
/// comes from the server outcome, not from validation.
|
||||
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
private func makeViewModel(
|
||||
store: any HostStore,
|
||||
recorder: ProbeRecorder
|
||||
) -> PairingViewModel {
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, token in await recorder.verify(endpoint, token) },
|
||||
validateToken: { _, token in await recorder.validate(token) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive the VM to the token prompt the way the user gets there: the host
|
||||
/// answers 401, which the probe reports as `originRejected` with the
|
||||
/// both-remedies hint.
|
||||
private func viewModelAtTokenPrompt(
|
||||
store: any HostStore = InMemoryHostStore(),
|
||||
recorder: ProbeRecorder
|
||||
) async -> PairingViewModel {
|
||||
let viewModel = makeViewModel(store: store, recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect()
|
||||
viewModel.beginAccessTokenEntry()
|
||||
// Loud, not silent: a recorder whose first verification does NOT fail
|
||||
// with a 401 would leave the VM elsewhere and make every assertion below
|
||||
// vacuous.
|
||||
if case .awaitingToken = viewModel.phase {} else {
|
||||
Issue.record("harness expected .awaitingToken, got \(viewModel.phase)")
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
// MARK: - 401 → prompt
|
||||
|
||||
@Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态")
|
||||
func unauthorizedFailureOffersTokenPrompt() async throws {
|
||||
// The hint text itself is the probe's (pinned in
|
||||
// PairingProbeUnauthorizedTests against the REAL probe); here it stands
|
||||
// in verbatim, because the VM must surface it unchanged.
|
||||
let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;"
|
||||
+ "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。"
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.action == .enterAccessToken)
|
||||
// The ambiguity is spelled out: BOTH remedies, since the server answers
|
||||
// 401 for a bad token and a rejected Origin alike.
|
||||
#expect(failure.message.contains("访问令牌"))
|
||||
#expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
|
||||
viewModel.beginAccessTokenEntry()
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("非 401 失败不给令牌入口(beginAccessTokenEntry 无副作用)")
|
||||
func nonUnauthorizedFailureHasNoTokenEntry() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
viewModel.beginAccessTokenEntry()
|
||||
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.action == .retry)
|
||||
}
|
||||
|
||||
// MARK: - Shape validation happens BEFORE any network I/O
|
||||
|
||||
@Test("令牌太短 → 本地就拒(不发 /auth),话术只带长度不带令牌")
|
||||
func tooShortTokenIsRejectedLocally() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken("short")
|
||||
|
||||
#expect(await recorder.validateCalls.isEmpty)
|
||||
let rejection = try #require(viewModel.tokenRejection)
|
||||
#expect(rejection.contains("\(5)"))
|
||||
#expect(!rejection.contains("short")) // never echo the value
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("令牌含非法字符 → 本地就拒,不发 /auth")
|
||||
func invalidCharactersRejectedLocally() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken("abcdefghijklmnop qrstuv")
|
||||
|
||||
#expect(await recorder.validateCalls.isEmpty)
|
||||
#expect(viewModel.tokenRejection?.isEmpty == false)
|
||||
}
|
||||
|
||||
// MARK: - The four §1.1 outcomes
|
||||
|
||||
@Test("204+Set-Cookie(valid)→ 带令牌重跑探针,主机连令牌一起入库")
|
||||
func validTokenIsSavedWithTheHost() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// The re-verification carried the candidate token (2nd verify call).
|
||||
let verifyCalls = await recorder.verifyCalls
|
||||
#expect(verifyCalls.count == 2)
|
||||
#expect(verifyCalls.first?.token == nil)
|
||||
#expect(verifyCalls.last?.token == Self.goodToken)
|
||||
// …and the persisted record has it (Keychain in production).
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(host.accessToken?.rawValue == Self.goodToken)
|
||||
let stored = try await store.loadAll()
|
||||
#expect(stored.count == 1)
|
||||
#expect(stored.first?.accessToken?.rawValue == Self.goodToken)
|
||||
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
|
||||
}
|
||||
|
||||
@Test("204 但没有 Set-Cookie(该主机未启用鉴权)→ 绝不保存令牌,也不声称已认证")
|
||||
func authDisabledNeverPersistsAToken() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.authDisabled)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Still in the prompt, told the truth, and NOTHING was stored.
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
// No second verification either — re-probing unauthenticated would just
|
||||
// reproduce the same failure (the cause is not the token).
|
||||
#expect(await recorder.verifyCalls.count == 1)
|
||||
}
|
||||
|
||||
@Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)")
|
||||
func authDisabledClearsTheCandidate() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.authDisabled)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Back to confirm, then a (now succeeding) retry of the whole flow.
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(host.accessToken == nil)
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
}
|
||||
|
||||
@Test("401(令牌错)→ 留在输入态给出「令牌不正确」,不入库")
|
||||
func invalidTokenKeepsThePrompt() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.invalidToken)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenInvalid)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → 「稍后再试」话术,不入库")
|
||||
func rateLimitedKeepsThePrompt() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.rateLimited)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited)
|
||||
}
|
||||
|
||||
@Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)")
|
||||
func transportFailureIsSurfaced() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.failure(.unreachable("Connection refused"))]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
let rejection = try #require(viewModel.tokenRejection)
|
||||
#expect(rejection.contains("Connection refused"))
|
||||
#expect(rejection != PairingCopy.tokenInvalid)
|
||||
}
|
||||
|
||||
// MARK: - Recovering an already-paired host
|
||||
|
||||
@Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌)")
|
||||
func repairingSameOriginUpdatesInPlace() async throws {
|
||||
// Arrange: a host paired before the server enabled WEBTERM_TOKEN.
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
_ = try await store.upsert(existing)
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
// Act
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Assert: ONE host, same id (every lastSessionId/watermark keyed by it
|
||||
// survives), original name kept, token now present.
|
||||
let hosts = try await store.loadAll()
|
||||
#expect(hosts.count == 1)
|
||||
#expect(hosts.first?.id == existing.id)
|
||||
#expect(hosts.first?.name == "书房 Mac")
|
||||
#expect(hosts.first?.accessToken?.rawValue == Self.goodToken)
|
||||
}
|
||||
|
||||
@Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP")
|
||||
func repairingKeepsUserChosenName() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
)
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.name == "书房 Mac")
|
||||
}
|
||||
|
||||
@Test("重新配对时用户改了名字 → 采用新名字")
|
||||
func repairingAdoptsEditedName() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint)
|
||||
)
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
viewModel.hostName = "客厅 Mac"
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.name == "客厅 Mac")
|
||||
}
|
||||
|
||||
@Test("重新配对(未输新令牌)不会丢掉已保存的令牌")
|
||||
func repairingPreservesStoredToken() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
let token = try AccessToken(validating: Self.goodToken)
|
||||
_ = try await store.upsert(HostRegistry.Host(
|
||||
id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token
|
||||
))
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.accessToken == token)
|
||||
}
|
||||
|
||||
// MARK: - Secret hygiene
|
||||
|
||||
@Test("取消令牌输入 → 回到确认页,候选令牌被丢弃")
|
||||
func cancellingTokenEntryDropsTheCandidate() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
|
||||
guard case .confirming = viewModel.phase else {
|
||||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(viewModel.tokenRejection == nil)
|
||||
await viewModel.confirmConnect()
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
}
|
||||
|
||||
@Test("换一个配对目标不会继承上一个目标的令牌")
|
||||
func newTargetDoesNotInheritTheToken() async throws {
|
||||
let recorder = ProbeRecorder(validateResults: [.success(.valid)])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect() // succeeds → paired
|
||||
// A fresh VM state is what the sheet gives on re-entry; here we assert the
|
||||
// reset path itself.
|
||||
viewModel.cancel()
|
||||
viewModel.submitManualURL("http://192.168.1.9:3000")
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
#expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000")
|
||||
}
|
||||
|
||||
@Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)")
|
||||
func tokenNeverLeaksIntoObservableState() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(!String(describing: viewModel.phase).contains(Self.goodToken))
|
||||
#expect(!host.description.contains(Self.goodToken))
|
||||
#expect(host.description.contains("hasAccessToken: true")) // presence only
|
||||
#expect(viewModel.tokenRejection == nil)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,14 @@ struct PairingViewModelTests {
|
||||
store: any HostStore = InMemoryHostStore(),
|
||||
script: ProbeScript
|
||||
) -> PairingViewModel {
|
||||
PairingViewModel(store: store, probe: { await script.invoke($0) })
|
||||
// C1 · `Probe` is now a value carrying the three capabilities; the
|
||||
// token/push ones keep their zero-config defaults for these tests.
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(verifyHost: { endpoint, _ in
|
||||
await script.invoke(endpoint)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
@@ -67,7 +74,11 @@ struct PairingViewModelTests {
|
||||
let store = InMemoryHostStore()
|
||||
let viewModel = PairingViewModel(
|
||||
store: store,
|
||||
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
|
||||
probe: PairingViewModel.Probe(verifyHost: { endpoint, token in
|
||||
await runPairingProbe(
|
||||
endpoint: endpoint, http: http, ws: ws, accessToken: token?.rawValue
|
||||
)
|
||||
})
|
||||
)
|
||||
let base = "http://192.168.1.5:3000"
|
||||
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||||
@@ -270,8 +281,10 @@ struct PairingViewModelTests {
|
||||
(PairingError.httpOkButNotWebTerminal,
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["端口"]),
|
||||
// C1 · 401 is ambiguous (Origin OR access token, same status), so the
|
||||
// primary offered remedy is now the token prompt; retry stays on screen.
|
||||
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
PairingViewModel.RecoveryAction.enterAccessToken,
|
||||
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
|
||||
(PairingError.atsBlocked(host: "198.18.0.1"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ProjectOpenWiringTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) }
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
99
ios/App/WebTermTests/ProjectResumeLaunchTests.swift
Normal file
99
ios/App/WebTermTests/ProjectResumeLaunchTests.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32(C2)· 历史会话恢复的**发射端**:`ProjectsViewModel.requestResumeClaude`
|
||||
/// 必须走与"在此仓库开新会话"完全相同的 `attach(null, cwd)` 缝,只把 bootstrap 换成
|
||||
/// `claude --resume <id>\r`,并在铸造 request 之前拦下不可信的 cwd / 会话 id。
|
||||
@MainActor
|
||||
@Suite("ProjectResumeLaunch")
|
||||
struct ProjectResumeLaunchTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
private func makeViewModel() throws -> ProjectsViewModel {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
return ProjectsViewModel(host: host, http: FakeHTTPTransport())
|
||||
}
|
||||
|
||||
@Test("合法 cwd + 合法 id → OpenRequest 携带 `claude --resume <id>\\r`")
|
||||
func resumeBuildsBootstrapInput() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
let sessionId = "3f2b1c4d-0000-4000-8000-000000000001"
|
||||
|
||||
viewModel.requestResumeClaude(cwd: "/repos/web-terminal", sessionId: sessionId)
|
||||
|
||||
let request = try #require(viewModel.openRequest)
|
||||
#expect(request.cwd == "/repos/web-terminal")
|
||||
#expect(request.bootstrapInput == "claude --resume \(sessionId)\r")
|
||||
#expect(viewModel.openErrorMessage == nil)
|
||||
}
|
||||
|
||||
@Test("相对 cwd → 拒绝铸造(与 requestOpenClaude 同款纪律)")
|
||||
func relativeCwdIsRejected() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestResumeClaude(cwd: "repos/web-terminal", sessionId: "abc")
|
||||
|
||||
#expect(viewModel.openRequest == nil)
|
||||
#expect(viewModel.openErrorMessage != nil)
|
||||
}
|
||||
|
||||
@Test("危险会话 id → 拒绝铸造(绝不把它送进 PTY 命令行)")
|
||||
func injectionIdIsRejected() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestResumeClaude(
|
||||
cwd: "/repos/web-terminal", sessionId: "abc; rm -rf ~"
|
||||
)
|
||||
|
||||
#expect(viewModel.openRequest == nil)
|
||||
#expect(viewModel.openErrorMessage == ResumeCopy.notResumable)
|
||||
}
|
||||
|
||||
@Test("普通开会话仍是 `claude\\r`(恢复路径没有改动既有行为)")
|
||||
func plainOpenIsUnchanged() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestOpenClaude(cwd: "/repos/web-terminal")
|
||||
|
||||
#expect(viewModel.openRequest?.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
|
||||
}
|
||||
}
|
||||
|
||||
/// C2 · PR 链接白名单(`PrStatus.url` 是服务器字节 —— 交给 `openURL` 等于让远端
|
||||
/// 决定要唤起哪个 App)。
|
||||
@Suite("PrLink")
|
||||
struct PrLinkTests {
|
||||
@Test("https + github.com(含子域)→ 可点")
|
||||
func githubHttpsIsAllowed() {
|
||||
#expect(PrLink.url(from: "https://github.com/o/r/pull/7") != nil)
|
||||
#expect(PrLink.url(from: "https://www.github.com/o/r/pull/7") != nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"其余一律拒绝:http、自定义 scheme、非 github 主机、伪造后缀、nil",
|
||||
arguments: [
|
||||
"http://github.com/o/r/pull/7",
|
||||
"javascript:alert(1)",
|
||||
"webterm://join?id=1",
|
||||
"https://evil.com/o/r/pull/7",
|
||||
"https://github.com.evil.com/o/r/pull/7",
|
||||
"not a url at all",
|
||||
"",
|
||||
]
|
||||
)
|
||||
func everythingElseIsRejected(raw: String) {
|
||||
#expect(PrLink.url(from: raw) == nil, "\(raw) 不应被接受")
|
||||
}
|
||||
|
||||
@Test("nil URL(gh 降级时没有 url 字段)→ nil")
|
||||
func nilIsRejected() {
|
||||
#expect(PrLink.url(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史(`GET /sessions`)的 App 层归约。
|
||||
///
|
||||
/// RED 清单见 `docs/plans/ios-completion.md` §4(E 28–35)。安全重点:会话 id 是
|
||||
/// **不可信服务器字节**,而它会被拼进一条真的送进 PTY 的命令行 —— 因此白名单校验
|
||||
/// 是本任务的核心用例(web 侧 `public/tabs.ts:918` 缺这一层)。
|
||||
@MainActor
|
||||
@Suite("ResumeHistoryViewModel")
|
||||
struct ResumeHistoryViewModelTests {
|
||||
private static let projectPath = "/repos/web-terminal"
|
||||
|
||||
private nonisolated static func session(
|
||||
id: String = "3f2b1c4d-0000-4000-8000-000000000001",
|
||||
cwd: String,
|
||||
mtimeMs: Double = 1_785_390_645_813.5
|
||||
) -> HistorySession {
|
||||
HistorySession(
|
||||
id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 过滤(28–30)
|
||||
|
||||
@Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree)")
|
||||
func keepsOnlySessionsInsideProject() {
|
||||
let inside = Self.session(id: "a1", cwd: Self.projectPath)
|
||||
let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x")
|
||||
let outside = Self.session(id: "a3", cwd: "/repos/other")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [inside, nested, outside], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.map(\.id) == ["a1", "a2"])
|
||||
}
|
||||
|
||||
@Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal")
|
||||
func siblingPrefixIsNotInside() {
|
||||
let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [sibling], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.isEmpty)
|
||||
}
|
||||
|
||||
@Test("服务器顺序(mtime 新→旧)原样保留,客户端不重排")
|
||||
func serverOrderIsPreserved() {
|
||||
let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000)
|
||||
let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000)
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [newer, older], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.map(\.id) == ["new", "old"])
|
||||
}
|
||||
|
||||
@Test("cwd 非绝对路径 → 不可恢复(Validation.isAbsoluteCwd 纪律)")
|
||||
func relativeCwdIsFilteredOut() {
|
||||
let relative = Self.session(id: "a1", cwd: "repos/web-terminal")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [relative], projectPath: "repos/web-terminal"
|
||||
)
|
||||
|
||||
#expect(candidates.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - phase(31–32)
|
||||
|
||||
@Test("空结果 → .empty(不是 .failed:服务器无历史时正常回 [])")
|
||||
func emptyListIsNotFailure() async {
|
||||
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty)
|
||||
}
|
||||
|
||||
@Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty")
|
||||
func allFilteredOutIsEmpty() async {
|
||||
let vm = ResumeHistoryViewModel(
|
||||
projectPath: Self.projectPath,
|
||||
fetch: { [Self.session(id: "a1", cwd: "/repos/other")] }
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty)
|
||||
}
|
||||
|
||||
@Test("加载失败 → .failed(可重试),重试成功 → .loaded")
|
||||
func failureIsRetryable() async {
|
||||
let flag = ResumeFailOnceFlag()
|
||||
let payload = Self.session(id: "a1", cwd: Self.projectPath)
|
||||
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: {
|
||||
if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable }
|
||||
return [payload]
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
guard case .failed = vm.phase else {
|
||||
Issue.record("应为 .failed,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates(
|
||||
from: [payload], projectPath: Self.projectPath
|
||||
)))
|
||||
}
|
||||
|
||||
// MARK: - 命令合成(33–34,安全)
|
||||
|
||||
@Test(
|
||||
"危险 id 绝不拼进 PTY 命令行(注入白名单)",
|
||||
arguments: [
|
||||
"abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'",
|
||||
"abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "",
|
||||
]
|
||||
)
|
||||
func dangerousIdsAreRejected(sessionId: String) {
|
||||
#expect(
|
||||
ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil,
|
||||
"\(sessionId) 不应被接受"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("超长 id(> 128)拒绝")
|
||||
func overlongIdRejected() {
|
||||
let long = String(repeating: "a", count: 129)
|
||||
|
||||
#expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil)
|
||||
}
|
||||
|
||||
@Test("合法 id → `claude --resume <id>` 且以 \\r(0x0D)结尾,绝不是 \\n")
|
||||
func validIdBuildsCarriageReturnCommand() throws {
|
||||
let id = "3f2b1c4d-0000-4000-8000-000000000001"
|
||||
|
||||
let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id))
|
||||
|
||||
#expect(input == "claude --resume \(id)\r")
|
||||
#expect(input.hasSuffix("\r"))
|
||||
#expect(!input.contains("\n"))
|
||||
}
|
||||
|
||||
@Test("非法 id 的历史行仍显示,但 canResume == false(不提供恢复按钮)")
|
||||
func rowWithBadIdIsNotResumable() {
|
||||
let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath)
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [bad], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.count == 1)
|
||||
#expect(!candidates[0].canResume)
|
||||
}
|
||||
|
||||
@Test("合法行 canResume == true,且携带会话自己的 cwd(worktree 会话回到 worktree)")
|
||||
func resumableRowCarriesItsOwnCwd() throws {
|
||||
let nestedCwd = Self.projectPath + "/.claude/worktrees/x"
|
||||
let session = Self.session(id: "a1", cwd: nestedCwd)
|
||||
|
||||
let candidate = try #require(ResumeHistoryViewModel.candidates(
|
||||
from: [session], projectPath: Self.projectPath
|
||||
).first)
|
||||
|
||||
#expect(candidate.canResume)
|
||||
#expect(candidate.cwd == nestedCwd)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性失败开关(@Sendable fetch 闭包里的可变状态 → actor 隔离)。
|
||||
private actor ResumeFailOnceFlag {
|
||||
private var shouldFail = true
|
||||
|
||||
func consumeShouldFail() -> Bool {
|
||||
defer { shouldFail = false }
|
||||
return shouldFail
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ struct SessionSwitcherTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ struct SidebarSelectionTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
315
ios/App/WebTermTests/TerminalSearchTests.swift
Normal file
315
ios/App/WebTermTests/TerminalSearchTests.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import SwiftTerm
|
||||
import TestSupport
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-33 · 终端内搜索(ios-completion §4 RED 清单 1–18)。
|
||||
///
|
||||
/// 契约来源:SwiftTerm 1.13.0 自带的搜索 API(`findNext`/`findPrevious`/
|
||||
/// `clearSearch`,`SearchOptions` 默认与 xterm.js search addon 一致)+ web 参照
|
||||
/// `public/search.ts`(Enter=next / Shift+Enter=prev / Esc=关闭并 clear)。
|
||||
///
|
||||
/// 铁律:搜索是**纯读** —— 整个流程不得产生一个 PTY 字节(byte-shuttle 不变式)。
|
||||
@MainActor
|
||||
@Suite("TerminalSearch (T-iOS-33)")
|
||||
struct TerminalSearchTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
/// 记录调用序列的搜索引擎替身:命中与否由测试注入。
|
||||
@MainActor
|
||||
private final class FakeSearcher: TerminalSearching {
|
||||
enum Call: Equatable {
|
||||
case next(String)
|
||||
case previous(String)
|
||||
case clear
|
||||
}
|
||||
|
||||
private(set) var calls: [Call] = []
|
||||
var hitResult = true
|
||||
|
||||
func searchNext(term: String) -> Bool {
|
||||
calls = calls + [.next(term)]
|
||||
return hitResult
|
||||
}
|
||||
|
||||
func searchPrevious(term: String) -> Bool {
|
||||
calls = calls + [.previous(term)]
|
||||
return hitResult
|
||||
}
|
||||
|
||||
func searchClear() {
|
||||
calls = calls + [.clear]
|
||||
}
|
||||
}
|
||||
|
||||
private func model(_ searcher: FakeSearcher) -> TerminalSearchModel {
|
||||
let model = TerminalSearchModel()
|
||||
model.attach(searcher)
|
||||
return model
|
||||
}
|
||||
|
||||
// MARK: - A. 查询提交策略(1–3)
|
||||
|
||||
@Test("1 · 空串不可提交,且零引擎调用")
|
||||
func emptyQueryIsNotSubmittable() {
|
||||
#expect(!TerminalSearchQuery.isSubmittable(""))
|
||||
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = ""
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls.isEmpty)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("2 · 单个空格可提交(镜像 web:空格是合法搜索词,绝不 trim 掉)")
|
||||
func singleSpaceIsSubmittable() {
|
||||
#expect(TerminalSearchQuery.isSubmittable(" "))
|
||||
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = " "
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next(" ")])
|
||||
}
|
||||
|
||||
@Test("3 · 查询词逐字符原样下传(不 trim、不改大小写)")
|
||||
func queryIsPassedThroughVerbatim() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = " Foo "
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next(" Foo ")])
|
||||
}
|
||||
|
||||
// MARK: - B. 方向与引擎调用(4–9)
|
||||
|
||||
@Test("4 · find(.next) → 恰一次 searchNext,零 searchPrevious")
|
||||
func findNextCallsNextOnly() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next("claude")])
|
||||
}
|
||||
|
||||
@Test("5 · find(.previous) → 恰一次 searchPrevious,零 searchNext")
|
||||
func findPreviousCallsPreviousOnly() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.previous)
|
||||
|
||||
#expect(searcher.calls == [.previous("claude")])
|
||||
}
|
||||
|
||||
@Test("6 · 引擎命中 → outcome == .found")
|
||||
func hitProducesFoundOutcome() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = true
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .found)
|
||||
}
|
||||
|
||||
@Test("7 · 引擎未命中 → outcome == .notFound,且有非空文案")
|
||||
func missProducesNotFoundOutcomeWithCopy() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .notFound)
|
||||
let status = model.statusText
|
||||
#expect(status != nil)
|
||||
#expect(!(status ?? "").isEmpty)
|
||||
}
|
||||
|
||||
@Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)")
|
||||
func repeatedFindHitsEngineEachTime() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "x"
|
||||
model.find(.next)
|
||||
model.find(.next)
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next("x"), .next("x"), .next("x")])
|
||||
}
|
||||
|
||||
@Test("9 · 未 attach 引擎 → 不崩,outcome 保持 .idle")
|
||||
func noSearcherAttachedIsSafe() {
|
||||
let model = TerminalSearchModel()
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .idle)
|
||||
#expect(model.statusText == nil)
|
||||
}
|
||||
|
||||
// MARK: - C. 打开/关闭生命周期(10–14)
|
||||
|
||||
@Test("10 · 初始 isPresented == false 且 outcome == .idle")
|
||||
func initialStateIsClosedAndIdle() {
|
||||
let model = TerminalSearchModel()
|
||||
|
||||
#expect(!model.isPresented)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("11 · present()/dismiss() 切换 isPresented")
|
||||
func presentAndDismissTogglePresentation() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
|
||||
model.present()
|
||||
#expect(model.isPresented)
|
||||
|
||||
model.dismiss()
|
||||
#expect(!model.isPresented)
|
||||
}
|
||||
|
||||
@Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位")
|
||||
func dismissClearsHighlightAndQuery() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
#expect(model.outcome == .notFound)
|
||||
|
||||
model.dismiss()
|
||||
|
||||
#expect(searcher.calls == [.next("zzz"), .clear])
|
||||
#expect(model.query.isEmpty)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)")
|
||||
func presentDoesNotTouchEngine() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
|
||||
#expect(searcher.calls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("14 · 改 query → outcome 复位 .idle(旧「无匹配」不挂新词)")
|
||||
func editingQueryResetsOutcome() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
#expect(model.outcome == .notFound)
|
||||
|
||||
model.query = "zz"
|
||||
|
||||
#expect(model.outcome == .idle)
|
||||
#expect(model.statusText == nil)
|
||||
}
|
||||
|
||||
// MARK: - D. 不变式:搜索是纯读(15–16)
|
||||
|
||||
@Test("15 · 整个搜索流程后零 PTY 字节(byte-shuttle 不变式)")
|
||||
func searchNeverWritesToThePty() async throws {
|
||||
// Arrange:真 SessionEngine over FakeTransport + 真 TerminalViewModel。
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
terminalViewModel.start()
|
||||
|
||||
// Act:开面板 → 搜前后 → 关面板。
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
model.find(.previous)
|
||||
model.dismiss()
|
||||
|
||||
// Assert:一个字节都没进 PTY。
|
||||
#expect(terminalViewModel.forwardedSendCount == 0)
|
||||
#expect(terminalViewModel.droppedReadOnlyInputCount == 0)
|
||||
}
|
||||
|
||||
@Test("16 · 终端已退出(read-only)时搜索照常可用")
|
||||
func searchWorksOnAnExitedTerminal() async throws {
|
||||
// Arrange:把 VM 推到 .exited。
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
terminalViewModel.start()
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#)
|
||||
await terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
#expect(terminalViewModel.isReadOnly)
|
||||
|
||||
// Act
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
// Assert:搜索是纯读,退出后仍然可用。
|
||||
#expect(searcher.calls == [.next("claude")])
|
||||
#expect(model.outcome == .found)
|
||||
}
|
||||
|
||||
// MARK: - E. SwiftTerm 绑定(17–18,Accept:命中并高亮)
|
||||
|
||||
/// 真 `KeyCommandTerminalView`(非替身):喂一段输出,再用 `TerminalSearching`
|
||||
/// 的三个方法打 SwiftTerm 自带搜索,命中即选区高亮。
|
||||
@Test("17 · 真 SwiftTerm view:命中返回 true 并高亮选区;未命中返回 false")
|
||||
func realTerminalViewSearchHitsAndHighlights() {
|
||||
let terminal = KeyCommandTerminalView(
|
||||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||||
)
|
||||
terminal.feed(text: "claude code cockpit\r\n")
|
||||
|
||||
let searcher: any TerminalSearching = terminal
|
||||
#expect(searcher.searchNext(term: "cockpit"))
|
||||
#expect(terminal.hasActiveSelection)
|
||||
|
||||
#expect(!searcher.searchNext(term: "nonexistent-needle"))
|
||||
}
|
||||
|
||||
@Test("18 · searchClear() 清掉高亮")
|
||||
func clearRemovesHighlight() {
|
||||
let terminal = KeyCommandTerminalView(
|
||||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||||
)
|
||||
terminal.feed(text: "claude code cockpit\r\n")
|
||||
let searcher: any TerminalSearching = terminal
|
||||
#expect(searcher.searchNext(term: "cockpit"))
|
||||
#expect(terminal.hasActiveSelection)
|
||||
|
||||
searcher.searchClear()
|
||||
|
||||
#expect(!terminal.hasActiveSelection)
|
||||
}
|
||||
}
|
||||
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The 401 terminal state end to end through the VM (B3 added
|
||||
/// `FailureReason.unauthorized`; this pins how the terminal presents it).
|
||||
///
|
||||
/// Driven through a REAL `SessionEngine` over `TestSupport.FakeTransport`, so
|
||||
/// the assertion covers the whole path a wrong token actually takes:
|
||||
/// upgrade 401 → `TermTransportError.unauthorized` → engine terminal state →
|
||||
/// `SessionEvent.connection(.failed(.unauthorized))` → VM phase + copy.
|
||||
@MainActor
|
||||
@Suite("Terminal unauthorized (401)")
|
||||
struct TerminalUnauthorizedTests {
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let engine: SessionEngine
|
||||
let viewModel: TerminalViewModel
|
||||
|
||||
init() throws {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
viewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
/// Upgrade rejected with 401 → `.connecting` + `.failed(.unauthorized)`.
|
||||
func openRejected() async {
|
||||
await transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await viewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("401 升级被拒 → 终态 failed(不是 reconnecting 转圈)")
|
||||
func unauthorizedIsTerminalNotRetried() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(harness.viewModel.banner == .none) // spinner cleared
|
||||
#expect(harness.viewModel.isReadOnly)
|
||||
#expect(await harness.transport.connectAttempts.count == 1) // no backoff loop
|
||||
}
|
||||
|
||||
@Test("401 话术同时给出两条补救:令牌与 ALLOWED_ORIGINS(服务端两者都回 401)")
|
||||
func unauthorizedCopyOffersBothRemedies() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed(let message) = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("访问令牌"))
|
||||
#expect(message.contains("ALLOWED_ORIGINS"))
|
||||
#expect(message == TerminalViewModel.unauthorizedMessage)
|
||||
}
|
||||
|
||||
@Test("401 横幅模型走 failed 分支(供 ReconnectBanner 渲染)")
|
||||
func unauthorizedRendersFailedBanner() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
#expect(
|
||||
harness.viewModel.bannerModel
|
||||
== .failed(message: TerminalViewModel.unauthorizedMessage)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("401 终态下输入被丢弃(read-only,不再打向已死连接)")
|
||||
func unauthorizedDropsInput() async throws {
|
||||
let harness = try Harness()
|
||||
await harness.openRejected()
|
||||
|
||||
harness.viewModel.sendInput("ls\r")
|
||||
|
||||
#expect(harness.viewModel.droppedReadOnlyInputCount == 1)
|
||||
}
|
||||
}
|
||||
635
ios/App/WebTermTests/VoicePTTTests.swift
Normal file
635
ios/App/WebTermTests/VoicePTTTests.swift
Normal file
@@ -0,0 +1,635 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-31 · 语音 PTT + 确认(ios-completion §4 RED 清单 19–56)。
|
||||
///
|
||||
/// 三件套逐条移植 web:`public/voice.ts`(PTT 生命周期、`autoSend:false` ⇒ 绝不
|
||||
/// 自动回车)、`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + 0.6 置信度
|
||||
/// 门)、`public/voice-confirm.ts`(1500ms 可撤销窗口)。epoch 防误发沿用
|
||||
/// `SessionCore/GateState.canDecide(epoch:)` 的既有先例。
|
||||
///
|
||||
/// 识别器经协议注入 —— 真机口述属 DEFERRED(需硬件麦克风),本套用替身把
|
||||
/// **匹配器 / 撤销窗口 / epoch 闸门**全部钉死,零真睡(FakeClock)。
|
||||
@MainActor
|
||||
@Suite("VoicePTT (T-iOS-31)")
|
||||
struct VoicePTTTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
/// 一次性闸门:让被测代码停在某个 `await` 上,测试侧确定性放行(零轮询、
|
||||
/// 零真睡 —— 全员在 MainActor 上,注册先于挂起,故顺序是确定的)。
|
||||
@MainActor
|
||||
private final class Gate {
|
||||
var isArmed = false
|
||||
private var didReach = false
|
||||
private var blocked: CheckedContinuation<Void, Never>?
|
||||
private var arrival: CheckedContinuation<Void, Never>?
|
||||
|
||||
/// 被测代码经过闸门;armed 时在此挂起。
|
||||
func passThrough() async {
|
||||
guard isArmed else { return }
|
||||
didReach = true
|
||||
arrival?.resume()
|
||||
arrival = nil
|
||||
await withCheckedContinuation { blocked = $0 }
|
||||
}
|
||||
|
||||
/// 等到被测代码真的到达闸门。
|
||||
func waitUntilReached() async {
|
||||
guard !didReach else { return }
|
||||
await withCheckedContinuation { arrival = $0 }
|
||||
}
|
||||
|
||||
func open() {
|
||||
isArmed = false
|
||||
let blocked = self.blocked
|
||||
self.blocked = nil
|
||||
blocked?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别器替身:授权结果、抛错、最终转写全部由测试注入;两个闸门可确定性
|
||||
/// 复现「授权期间松手」与「录音启动期间松手」两种 PTT 竞态。
|
||||
@MainActor
|
||||
private final class FakeDictation: VoiceDictating {
|
||||
var authorization: VoiceAuthorization = .granted
|
||||
var startError: (any Error)?
|
||||
var result = VoiceDictationResult(transcript: "", confidence: nil)
|
||||
private(set) var startCount = 0
|
||||
private(set) var stopCount = 0
|
||||
private(set) var partialSink: (@MainActor (String) -> Void)?
|
||||
let authorizationGate = Gate()
|
||||
let startGate = Gate()
|
||||
|
||||
func requestAuthorization() async -> VoiceAuthorization {
|
||||
await authorizationGate.passThrough()
|
||||
return authorization
|
||||
}
|
||||
|
||||
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
|
||||
startCount += 1
|
||||
if let startError { throw startError }
|
||||
partialSink = onPartial
|
||||
await startGate.passThrough()
|
||||
}
|
||||
|
||||
func stop() async -> VoiceDictationResult {
|
||||
stopCount += 1
|
||||
partialSink = nil
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// 可变 epoch 源(测试侧模拟会话切换/重连)。
|
||||
@MainActor
|
||||
private final class EpochBox {
|
||||
var epoch: VoiceEpoch
|
||||
|
||||
init(_ epoch: VoiceEpoch = VoiceEpoch(sessionId: nil, generation: 0)) {
|
||||
self.epoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入出口记录仪:断言「零注入」/「恰一次注入」的唯一判据。
|
||||
@MainActor
|
||||
private final class InjectRecorder {
|
||||
private(set) var texts: [String] = []
|
||||
private(set) var decisions: [VoiceDecision] = []
|
||||
|
||||
func inject(_ text: String) { texts = texts + [text] }
|
||||
func decide(_ decision: VoiceDecision) { decisions = decisions + [decision] }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let dictation = FakeDictation()
|
||||
let clock = FakeClock()
|
||||
let epochBox = EpochBox()
|
||||
let recorder = InjectRecorder()
|
||||
let viewModel: VoicePTTViewModel
|
||||
|
||||
init(isReadOnly: Bool = false, gate: VoiceGateSnapshot? = nil) {
|
||||
let dictation = self.dictation
|
||||
let clock = self.clock
|
||||
let epochBox = self.epochBox
|
||||
let recorder = self.recorder
|
||||
let bridge: VoiceGateBridge? = gate.map { snapshot in
|
||||
VoiceGateBridge(
|
||||
snapshot: { snapshot },
|
||||
decide: { decision in recorder.decide(decision) }
|
||||
)
|
||||
}
|
||||
viewModel = VoicePTTViewModel(
|
||||
dictation: dictation,
|
||||
clock: clock,
|
||||
epoch: { epochBox.epoch },
|
||||
isReadOnly: { isReadOnly },
|
||||
inject: { text in recorder.inject(text) },
|
||||
gate: bridge
|
||||
)
|
||||
}
|
||||
|
||||
/// 完整一次口述:按下 → 松手,转写由 `dictation.result` 提供。
|
||||
func dictate(_ transcript: String, confidence: Double? = nil) async {
|
||||
dictation.result = VoiceDictationResult(
|
||||
transcript: transcript, confidence: confidence
|
||||
)
|
||||
await viewModel.pressDown()
|
||||
await viewModel.pressUp()
|
||||
}
|
||||
|
||||
/// 推进到撤销窗口到期并等注入落地(零真睡)。
|
||||
func elapseUndoWindow() async {
|
||||
await clock.waitForSleepers(count: 1)
|
||||
clock.advance(by: VoicePTTViewModel.undoWindow)
|
||||
await viewModel.waitUntilWindowSettled()
|
||||
}
|
||||
}
|
||||
|
||||
private static let sessionA = UUID()
|
||||
private static let sessionB = UUID()
|
||||
|
||||
// MARK: - A. 转写清洗(19–24)
|
||||
|
||||
@Test("19 · 普通文本 → 首尾 trim 后原样")
|
||||
func sanitizeKeepsPlainText() {
|
||||
#expect(VoiceTranscript.sanitize(" git status ") == "git status")
|
||||
}
|
||||
|
||||
@Test("20 · \\r(0x0D)被剥除 —— 口述绝不自己回车执行命令")
|
||||
func sanitizeStripsCarriageReturn() {
|
||||
let sanitized = VoiceTranscript.sanitize("rm -rf /\r")
|
||||
#expect(!sanitized.contains("\r"))
|
||||
#expect(sanitized == "rm -rf /")
|
||||
}
|
||||
|
||||
@Test("21 · C0/C1 控制字符(\\n \\t ESC BEL DEL)全部剥除")
|
||||
func sanitizeStripsControlCharacters() {
|
||||
let raw = "ec\u{1b}ho\u{7}\t hi\u{7f}\u{0}\u{9b}"
|
||||
let sanitized = VoiceTranscript.sanitize(raw)
|
||||
#expect(sanitized == "echo hi")
|
||||
}
|
||||
|
||||
@Test("22 · 多行折成单行(换行→空格 + 合并连续空白)")
|
||||
func sanitizeCollapsesToOneLine() {
|
||||
#expect(VoiceTranscript.sanitize("git\ncommit -m\n\n test") == "git commit -m test")
|
||||
}
|
||||
|
||||
@Test("23 · 超长转写截断到上限")
|
||||
func sanitizeTruncatesOverlongTranscript() {
|
||||
let raw = String(repeating: "a", count: VoiceTranscript.maxLength + 500)
|
||||
#expect(VoiceTranscript.sanitize(raw).count == VoiceTranscript.maxLength)
|
||||
}
|
||||
|
||||
@Test("24 · 清洗后为空 → 不可注入")
|
||||
func sanitizeEmptyIsNotInjectable() {
|
||||
#expect(!VoiceTranscript.isInjectable(VoiceTranscript.sanitize("\u{1b}\r\n \t")))
|
||||
#expect(VoiceTranscript.isInjectable("ok"))
|
||||
}
|
||||
|
||||
// MARK: - B. 匹配器移植(25–33)
|
||||
|
||||
private func context(
|
||||
pendingApproval: Bool, gate: VoiceGateKind?, confidence: Double? = nil
|
||||
) -> VoiceMatchContext {
|
||||
VoiceMatchContext(pendingApproval: pendingApproval, gate: gate, confidence: confidence)
|
||||
}
|
||||
|
||||
@Test("25 · 无 held gate → 任何话语都是 .text(含「确认」)")
|
||||
func matcherFallsThroughWithoutHeldGate() {
|
||||
let ctx = context(pendingApproval: false, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "approve", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("26 · gate 是 .plan → .text(v1 只作用 tool gate)")
|
||||
func matcherIgnoresPlanGate() {
|
||||
let ctx = context(pendingApproval: true, gate: .plan)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("27 · tool gate + 肯定短语 → .approve")
|
||||
func matcherApprovesAffirmativePhrases() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
for phrase in ["确认", "批准", "同意", "好的", "ok", "go ahead", "proceed"] {
|
||||
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .approve,
|
||||
"\(phrase) 应判 approve")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("28 · 归一化:大小写 + 去标点 + 合并空白(don't → dont)")
|
||||
func matcherNormalizesTranscript() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "OK.", context: ctx) == .approve)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "好的!", context: ctx) == .approve)
|
||||
#expect(VoiceCommandMatcher.normalize("Don't DO it") == "dont do it")
|
||||
}
|
||||
|
||||
@Test("29 · 整句精确匹配 —— 「确认一下这个」落 .text(绝不子串匹配)")
|
||||
func matcherIsWholeUtteranceExact() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认一下这个", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "ok let's ship it", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("30 · tool gate + 否定短语 → .reject")
|
||||
func matcherRejectsNegativePhrases() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
for phrase in ["拒绝", "取消", "stop", "abort", "no"] {
|
||||
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .reject,
|
||||
"\(phrase) 应判 reject")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("31 · 否定守卫:「不批准」→ reject;「不清楚」→ text;now 不被 no 否定")
|
||||
func matcherNegationGuard() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "不批准", context: ctx) == .reject)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "不清楚", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "now", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.startsWithNegation("不批准"))
|
||||
#expect(!VoiceCommandMatcher.startsWithNegation("now"))
|
||||
}
|
||||
|
||||
@Test("32 · 置信度门:approve 低于 0.6 降级 text;reject 不受影响")
|
||||
func matcherConfidenceGateAppliesToApproveOnly() {
|
||||
let low = context(pendingApproval: true, gate: .tool, confidence: 0.4)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: low) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "取消", context: low) == .reject)
|
||||
|
||||
let atThreshold = context(
|
||||
pendingApproval: true, gate: .tool,
|
||||
confidence: VoiceCommandMatcher.minApproveConfidence
|
||||
)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: atThreshold) == .approve)
|
||||
}
|
||||
|
||||
@Test("33 · 空/纯标点话语 → .text")
|
||||
func matcherEmptyUtteranceIsText() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "……", context: ctx) == .text)
|
||||
}
|
||||
|
||||
// MARK: - C. 确认 + 1.5s 撤销窗口(34–43)
|
||||
|
||||
@Test("34 · pressDown → .listening;partial 回调更新 partial 文本")
|
||||
func pressDownStartsListeningAndSurfacesPartials() async {
|
||||
let harness = Harness()
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .listening(partial: ""))
|
||||
#expect(harness.dictation.startCount == 1)
|
||||
|
||||
harness.dictation.partialSink?("git st")
|
||||
#expect(harness.viewModel.phase == .listening(partial: "git st"))
|
||||
}
|
||||
|
||||
@Test("35 · 空转写 → .idle + .empty + 零注入")
|
||||
func emptyTranscriptResolvesEmpty() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate(" \r\n ")
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .empty)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("36 · 有效转写 → .confirming,且此刻零注入(确认前绝不注入)")
|
||||
func validTranscriptWaitsForExplicitConfirm() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .text("git status"),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "git status"
|
||||
)))
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("37 · .confirming 下 cancel() → .idle + .discarded + 零注入")
|
||||
func cancelDiscardsBeforeInjection() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.cancel()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .discarded)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("38 · confirm() → .undoWindow,此刻仍零注入")
|
||||
func confirmArmsUndoWindowWithoutInjecting() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.viewModel.isUndoWindowOpen)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("39 · +1.4s 仍零注入;到 1.5s → 恰一次注入 + .committed")
|
||||
func injectionLandsOnlyAfterTheFullUndoWindow() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
harness.clock.advance(by: .milliseconds(1400))
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
|
||||
harness.clock.advance(by: .milliseconds(100))
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts == ["git status"])
|
||||
#expect(harness.viewModel.lastResolution == .committed(.text("git status")))
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("40 · 撤销窗口内 undo() → 零注入 + .undone;再推进 10s 也不注入")
|
||||
func undoCancelsTheInjectionForGood() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
|
||||
harness.viewModel.undo()
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
harness.clock.advance(by: .seconds(10))
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .undone)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("41 · 注入内容不以 \\r 结尾(用户自己按 ⏎)")
|
||||
func injectedTextNeverEndsWithCarriageReturn() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("npm test")
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["npm test"])
|
||||
#expect(!(harness.recorder.texts.first ?? "\r").hasSuffix("\r"))
|
||||
}
|
||||
|
||||
@Test("42 · 非 .confirming 态调 confirm() → 无副作用")
|
||||
func confirmOutsideConfirmingIsNoop() async {
|
||||
let harness = Harness()
|
||||
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == nil)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("43 · 重复 confirm() 只 arm 一次、只注入一次")
|
||||
func repeatedConfirmArmsOnce() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.confirm()
|
||||
harness.viewModel.confirm()
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["git status"])
|
||||
}
|
||||
|
||||
// MARK: - D. epoch 防误发(44–48)
|
||||
|
||||
@Test("44 · 口述→确认之间 epoch 变了 → 零注入 + .staleEpoch")
|
||||
func epochChangeBeforeConfirmDiscards() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionB, generation: 0)
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("45 · epoch 在撤销窗口内变 → 到期仍零注入 + .staleEpoch(第二道闸)")
|
||||
func epochChangeInsideUndoWindowDiscards() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 1)
|
||||
harness.clock.advance(by: VoicePTTViewModel.undoWindow)
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
}
|
||||
|
||||
@Test("46 · epoch 不变 → 正常注入(防闸门过严的回归保护)")
|
||||
func stableEpochStillInjects() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 3)
|
||||
await harness.dictate("ls -la")
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["ls -la"])
|
||||
}
|
||||
|
||||
@Test("47 · 口述时 sessionId 未 adopt、确认时已 adopt → 视为变化 → 零注入")
|
||||
func dictatingBeforeAdoptionDiscardsAfterAdoption() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
}
|
||||
|
||||
@Test("48 · VoiceEpochPolicy:.reconnecting 作废 epoch;.connecting/.none 不作废")
|
||||
func epochPolicyInvalidatesOnReconnect() {
|
||||
#expect(VoiceEpochPolicy.invalidates(
|
||||
.reconnecting(attempt: 1, next: .seconds(1))
|
||||
))
|
||||
#expect(!VoiceEpochPolicy.invalidates(.connecting))
|
||||
#expect(!VoiceEpochPolicy.invalidates(.none))
|
||||
|
||||
let source = VoiceEpochSource()
|
||||
#expect(source.epoch == VoiceEpoch(sessionId: nil, generation: 0))
|
||||
source.noteBanner(.reconnecting(attempt: 1, next: .seconds(1)))
|
||||
source.noteBanner(.connecting)
|
||||
source.noteSession(Self.sessionA)
|
||||
#expect(source.epoch == VoiceEpoch(sessionId: Self.sessionA, generation: 1))
|
||||
}
|
||||
|
||||
// MARK: - E. 权限与失败路径(49–52)
|
||||
|
||||
@Test("49 · 麦克风授权被拒 → .denied(.microphone),零录音零注入")
|
||||
func microphoneDenialBlocksDictation() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorization = .deniedMicrophone
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .denied(.microphone))
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(VoiceDenialReason.microphone.message.contains("麦克风"))
|
||||
}
|
||||
|
||||
@Test("50 · 语音识别授权被拒 → .denied(.speech),文案指向语音识别开关")
|
||||
func speechDenialBlocksDictation() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorization = .deniedSpeech
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .denied(.speech))
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(VoiceDenialReason.speech.message.contains("语音识别"))
|
||||
}
|
||||
|
||||
@Test("51 · 识别器抛错 → .failed(message:),零注入,可再按重试")
|
||||
func recognizerFailureIsRecoverable() async {
|
||||
struct Boom: Error {}
|
||||
let harness = Harness()
|
||||
harness.dictation.startError = Boom()
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
guard case .failed(let message) = harness.viewModel.phase else {
|
||||
Issue.record("期望 .failed,实际 \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(!message.isEmpty)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
|
||||
harness.dictation.startError = nil
|
||||
await harness.viewModel.pressDown()
|
||||
#expect(harness.viewModel.phase == .listening(partial: ""))
|
||||
}
|
||||
|
||||
@Test("52 · 终端只读 → pressDown 拒绝,不启动录音")
|
||||
func readOnlyTerminalRefusesDictation() async {
|
||||
let harness = Harness(isReadOnly: true)
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .readOnly)
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
}
|
||||
|
||||
@Test("PTT 竞态 A:录音启动期间松手 → 启动完立刻收尾(麦克风绝不卡在开着)")
|
||||
func releaseDuringStartStillFinishes() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.startGate.isArmed = true
|
||||
harness.dictation.result = VoiceDictationResult(
|
||||
transcript: "git status", confidence: nil
|
||||
)
|
||||
|
||||
let down = Task { await harness.viewModel.pressDown() }
|
||||
await harness.dictation.startGate.waitUntilReached()
|
||||
let up = Task { await harness.viewModel.pressUp() }
|
||||
await up.value
|
||||
harness.dictation.startGate.open()
|
||||
await down.value
|
||||
|
||||
#expect(harness.dictation.stopCount == 1)
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .text("git status"),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "git status"
|
||||
)))
|
||||
}
|
||||
|
||||
@Test("PTT 竞态 B:授权期间就松手 → 麦克风一次都不开(零 start)")
|
||||
func releaseDuringAuthorizationNeverOpensTheMic() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorizationGate.isArmed = true
|
||||
|
||||
let down = Task { await harness.viewModel.pressDown() }
|
||||
await harness.dictation.authorizationGate.waitUntilReached()
|
||||
let up = Task { await harness.viewModel.pressUp() }
|
||||
await up.value
|
||||
harness.dictation.authorizationGate.open()
|
||||
await down.value
|
||||
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(harness.dictation.stopCount == 0)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
// MARK: - F. 键栏集成 + gate 桥(53–56)
|
||||
|
||||
@Test("53 · 不提供语音闭包 → 无麦克风键,按钮数不变(零回归)")
|
||||
func keyBarWithoutVoiceHandlersIsUnchanged() {
|
||||
let bar = KeyBarView()
|
||||
|
||||
#expect(bar.voiceButton == nil)
|
||||
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||
}
|
||||
|
||||
@Test("54 · 提供语音闭包 → 末尾多一个 🎤 键,前 17 键顺序/标签完全不变")
|
||||
func keyBarAppendsMicWithoutDisturbingExistingKeys() {
|
||||
let bar = KeyBarView(voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}))
|
||||
|
||||
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||
#expect(bar.keyButtons.map(\.accessibilityLabel)
|
||||
== KeyBarLayout.buttons.map(\.title))
|
||||
let mic = bar.voiceButton
|
||||
#expect(mic != nil)
|
||||
#expect(mic?.accessibilityLabel == KeyBarLayout.voiceTitle)
|
||||
}
|
||||
|
||||
@Test("55 · 麦克风键 touchDown/touchUpInside 各触发一次,绝不经 onKey")
|
||||
func micButtonRoutesPressAndReleaseOnly() throws {
|
||||
var down = 0
|
||||
var up = 0
|
||||
var keys: [KeyByteMap.Key] = []
|
||||
let bar = KeyBarView(voice: KeyBarVoiceHandlers(
|
||||
onDown: { down += 1 },
|
||||
onUp: { up += 1 }
|
||||
))
|
||||
bar.onKey = { key in keys = keys + [key] }
|
||||
let mic = try #require(bar.voiceButton)
|
||||
|
||||
mic.sendActions(for: .touchDown)
|
||||
#expect((down, up) == (1, 0))
|
||||
|
||||
mic.sendActions(for: .touchUpInside)
|
||||
#expect((down, up) == (1, 1))
|
||||
#expect(keys.isEmpty)
|
||||
}
|
||||
|
||||
@Test("56 · 匹配到 approve → 同一 1.5s 窗口,到期调 decide(.approve) 而不注入文本")
|
||||
func approveIntentGoesThroughTheSameWindow() async {
|
||||
let harness = Harness(gate: VoiceGateSnapshot(pendingApproval: true, gate: .tool))
|
||||
await harness.dictate("确认", confidence: 0.9)
|
||||
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .decision(.approve),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "确认"
|
||||
)))
|
||||
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.decisions == [.approve])
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .committed(.decision(.approve)))
|
||||
}
|
||||
}
|
||||
391
ios/App/WebTermTests/WorktreeViewModelTests.swift
Normal file
391
ios/App/WebTermTests/WorktreeViewModelTests.swift
Normal file
@@ -0,0 +1,391 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32 · worktree 生命周期(create / prune / remove)的 App 层归约。
|
||||
///
|
||||
/// RED 清单见 `docs/plans/ios-completion.md` §4(A 1–9、B 10–17、C 18–21、D 22–27)。
|
||||
/// APIClient 侧的 builder/请求体/状态码映射已由 B1 单测覆盖(125 tests),本套只测
|
||||
/// VM:校验先行、破坏性操作必须显式确认、服务器安全文案原样上浮。
|
||||
@MainActor
|
||||
@Suite("WorktreeViewModel")
|
||||
struct WorktreeViewModelTests {
|
||||
|
||||
// MARK: - A. 分支名校验(逐条镜像 public/projects.ts:982 validateBranchNameClient)
|
||||
|
||||
@Test("空分支名 → 报错")
|
||||
func emptyBranchRejected() {
|
||||
#expect(WorktreeBranchRule.validate("") != nil)
|
||||
}
|
||||
|
||||
@Test("长度:250 通过、251 报错")
|
||||
func lengthBoundary() {
|
||||
let ok = String(repeating: "a", count: 250)
|
||||
|
||||
#expect(WorktreeBranchRule.validate(ok) == nil)
|
||||
#expect(WorktreeBranchRule.validate(ok + "a") != nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"非法形态一律报错(空格/控制字符/前导-/../.lock/特殊字符/@{/斜杠误用)",
|
||||
arguments: [
|
||||
"feat x", "feat\tx", "feat\u{0}x", "feat\u{7f}x",
|
||||
"-feat", "feat..x", "feat.lock",
|
||||
"feat~x", "feat^x", "feat:x", "feat?x", "feat*x", "feat[x", "feat\\x",
|
||||
"feat@{1}", "/feat", "feat/", "feat//x",
|
||||
]
|
||||
)
|
||||
func invalidShapesRejected(branch: String) {
|
||||
#expect(WorktreeBranchRule.validate(branch) != nil, "\(branch) 应被拒绝")
|
||||
}
|
||||
|
||||
@Test("合法分支名通过", arguments: ["feat/x", "v1.2-fix", "worktree-ios_32"])
|
||||
func validNamesAccepted(branch: String) {
|
||||
#expect(WorktreeBranchRule.validate(branch) == nil)
|
||||
}
|
||||
|
||||
// MARK: - B. 创建
|
||||
|
||||
@Test("非法分支名 → 一次请求都不发(校验在边界,先于网络)")
|
||||
func invalidBranchSkipsNetwork() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "-bad"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.createCalls == 0)
|
||||
#expect(vm.createPhase != .creating)
|
||||
if case .failed(let message) = vm.createPhase {
|
||||
#expect(!message.isEmpty)
|
||||
} else {
|
||||
Issue.record("应为 .failed,实际 \(vm.createPhase)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("成功 → 采用服务器返回的 canonical path/branch(不回显本地输入)")
|
||||
func createAdoptsServerTruth() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
create: { _, _ in
|
||||
.ok(CreateWorktreeResult(path: "/repos/wt/feat-x", branch: "feat/x"))
|
||||
}
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .created(path: "/repos/wt/feat-x", branch: "feat/x"))
|
||||
}
|
||||
|
||||
@Test("base 留空 → 请求体 base 为 nil(服务器读作「从 HEAD 切」,绝不送空串)")
|
||||
func blankBaseBecomesNil() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "feat/x"
|
||||
vm.baseText = " "
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.lastBase == nil)
|
||||
}
|
||||
|
||||
@Test("填了 base → 去空白后原样送出")
|
||||
func trimmedBaseIsSent() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "feat/x"
|
||||
vm.baseText = " develop "
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.lastBase == "develop")
|
||||
}
|
||||
|
||||
@Test("403(kill-switch 或 Origin)→ 原样显示服务器安全文案")
|
||||
func rejectionSurfacesServerMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in .rejected(status: 403, message: "Worktrees are disabled.") }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .failed("Worktrees are disabled."))
|
||||
}
|
||||
|
||||
@Test("500 且服务器没给 message → 非空兜底中文文案")
|
||||
func rejectionWithoutMessageFallsBack() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in .rejected(status: 500, message: nil) }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
if case .failed(let message) = vm.createPhase {
|
||||
#expect(!message.isEmpty)
|
||||
#expect(message.contains("500"))
|
||||
} else {
|
||||
Issue.record("应为 .failed,实际 \(vm.createPhase)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → 限流专用文案,且不自动重试(只发一次)")
|
||||
func rateLimitedIsNotRetried() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, create: { _, _ in .rateLimited })
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.createCalls == 1)
|
||||
#expect(vm.createPhase == .failed(GitWriteFeedback.rateLimited))
|
||||
}
|
||||
|
||||
@Test("抛出 .unauthorized → 引导补令牌的话术(与 500 区分)")
|
||||
func unauthorizedUsesTokenCopy() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in throw APIClientError.unauthorized }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .failed(APIClientError.unauthorized.message))
|
||||
}
|
||||
|
||||
@Test("创建进行中重复提交 → 只发一次请求")
|
||||
func duplicateCreateSendsOneRequest() async {
|
||||
let gate = WorktreeGate()
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
create: { _, _ in
|
||||
await gate.wait()
|
||||
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: "feat/x"))
|
||||
}
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
let first = Task { await vm.create() }
|
||||
await Self.spin(until: { vm.createPhase == .creating })
|
||||
await vm.create() // 忙态:应被吞掉
|
||||
#expect(await spy.createCalls == 1)
|
||||
|
||||
await gate.release()
|
||||
await first.value
|
||||
}
|
||||
|
||||
// MARK: - C. prune(破坏性 → 调用方确认后才进 VM)
|
||||
|
||||
@Test("pruned 为空 → 幂等文案,非错误态")
|
||||
func pruneEmptyIsIdempotent() async {
|
||||
let vm = Self.makeViewModel(spy: WorktreeCallSpy(), prune: { .ok(PruneWorktreesResult(pruned: [])) })
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .done(WorktreeCopy.pruneNothing))
|
||||
}
|
||||
|
||||
@Test("pruned 有 2 条 → 文案带数量")
|
||||
func pruneReportsCount() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
prune: { .ok(PruneWorktreesResult(pruned: ["worktrees/a", "worktrees/b"])) }
|
||||
)
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .done(WorktreeCopy.pruneDone(2)))
|
||||
}
|
||||
|
||||
@Test("prune 404 → 显示服务器文案")
|
||||
func pruneRejectionSurfacesMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
prune: { .rejected(status: 404, message: "Not a git repository.") }
|
||||
)
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .failed("Not a git repository."))
|
||||
}
|
||||
|
||||
// MARK: - D. remove(两级确认)
|
||||
|
||||
@Test("主 worktree / 已锁定 worktree → 不提供删除入口")
|
||||
func mainAndLockedAreNotRemovable() {
|
||||
let main = WorktreeInfo(
|
||||
path: "/repos/r", branch: "develop", head: "a", isMain: true,
|
||||
isCurrent: true, locked: nil, prunable: nil
|
||||
)
|
||||
let locked = WorktreeInfo(
|
||||
path: "/repos/wt", branch: "feat/x", head: "b", isMain: false,
|
||||
isCurrent: false, locked: true, prunable: nil
|
||||
)
|
||||
let plain = WorktreeInfo(
|
||||
path: "/repos/wt2", branch: "feat/y", head: "c", isMain: false,
|
||||
isCurrent: false, locked: false, prunable: true
|
||||
)
|
||||
|
||||
#expect(!WorktreeViewModel.canRemove(main))
|
||||
#expect(!WorktreeViewModel.canRemove(locked))
|
||||
#expect(WorktreeViewModel.canRemove(plain))
|
||||
}
|
||||
|
||||
@Test("第一次确认 → force: false")
|
||||
func firstRemoveIsNotForced() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, remove: { _, _ in .ok(RemoveWorktreeResult(path: "/repos/wt")) })
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .removed(path: "/repos/wt"))
|
||||
}
|
||||
|
||||
@Test("409(脏工作树)→ 进入二次确认,绝不自动 force")
|
||||
func dirtyTreeAsksBeforeForcing() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, force in
|
||||
force
|
||||
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
|
||||
: .rejected(status: 409, message: "Worktree has uncommitted changes — force required.")
|
||||
}
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .forceConfirming(
|
||||
path: "/repos/wt",
|
||||
message: "Worktree has uncommitted changes — force required."
|
||||
))
|
||||
}
|
||||
|
||||
@Test("二次确认取消 → 不发第二次请求")
|
||||
func cancellingForceSendsNothing() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, _ in .rejected(status: 409, message: "dirty") }
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
vm.cancelForceRemove()
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .idle)
|
||||
}
|
||||
|
||||
@Test("二次确认通过 → 第二次请求 force: true")
|
||||
func confirmingForceRetriesWithForce() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, force in
|
||||
force
|
||||
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
|
||||
: .rejected(status: 409, message: "dirty")
|
||||
}
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
await vm.confirmForceRemove()
|
||||
|
||||
#expect(await spy.removeForceFlags == [false, true])
|
||||
#expect(vm.removePhase == .removed(path: "/repos/wt"))
|
||||
}
|
||||
|
||||
@Test("400(非 409)→ 直接失败,不进 force 分支")
|
||||
func nonConflictRejectionNeverOffersForce() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
remove: { _, _ in .rejected(status: 400, message: "Cannot remove the main worktree.") }
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/r")
|
||||
|
||||
#expect(vm.removePhase == .failed("Cannot remove the main worktree."))
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func makeViewModel(
|
||||
spy: WorktreeCallSpy,
|
||||
create: (@Sendable (String, String?) async throws -> GitWriteOutcome<CreateWorktreeResult>)? = nil,
|
||||
prune: (@Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>)? = nil,
|
||||
remove: (@Sendable (String, Bool) async throws -> GitWriteOutcome<RemoveWorktreeResult>)? = nil
|
||||
) -> WorktreeViewModel {
|
||||
WorktreeViewModel(dependencies: WorktreeViewModel.Dependencies(
|
||||
create: { branch, base in
|
||||
await spy.recordCreate(base: base)
|
||||
if let create { return try await create(branch, base) }
|
||||
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: branch))
|
||||
},
|
||||
prune: {
|
||||
await spy.recordPrune()
|
||||
if let prune { return try await prune() }
|
||||
return .ok(PruneWorktreesResult(pruned: []))
|
||||
},
|
||||
remove: { path, force in
|
||||
await spy.recordRemove(force: force)
|
||||
if let remove { return try await remove(path, force) }
|
||||
return .ok(RemoveWorktreeResult(path: path))
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// 有界自旋:等一个 MainActor 状态成立(永不无限挂起)。
|
||||
private static func spin(
|
||||
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
|
||||
) async {
|
||||
for _ in 0..<maxYields where !condition() {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 依赖调用记录(@Sendable 闭包里的可变状态 → actor 隔离)。
|
||||
private actor WorktreeCallSpy {
|
||||
private(set) var createCalls = 0
|
||||
private(set) var lastBase: String?
|
||||
private(set) var pruneCalls = 0
|
||||
private(set) var removeForceFlags: [Bool] = []
|
||||
|
||||
func recordCreate(base: String?) {
|
||||
createCalls += 1
|
||||
lastBase = base
|
||||
}
|
||||
|
||||
func recordPrune() {
|
||||
pruneCalls += 1
|
||||
}
|
||||
|
||||
func recordRemove(force: Bool) {
|
||||
removeForceFlags.append(force)
|
||||
}
|
||||
}
|
||||
|
||||
/// 让一次依赖调用停在半空中,以便测试忙态去重。
|
||||
private actor WorktreeGate {
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let pending = waiters
|
||||
waiters = []
|
||||
pending.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user