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

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

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

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

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

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

392 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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() }
}
}