Files
web-terminal/ios/App/WebTermTests/GitPanelViewModelTests.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

329 lines
12 KiB
Swift
Raw 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
/// C2 · git VMRED 4751 +
///
/// 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: - 4749
@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() }
}
}