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:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View 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` §4A 19B 1017C 1821D 2227
/// 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("403kill-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() }
}
}