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.
392 lines
13 KiB
Swift
392 lines
13 KiB
Swift
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() }
|
||
}
|
||
}
|