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

225 lines
8.2 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 · / / /
///
/// `docs/plans/w6-project-git-panel.md` web
/// `public/projects.ts:615-745 makeSyncBand` "
/// 绿"RED 3647 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 3643
@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("从未 fetchlastFetchMs == 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(
"dirtyCountnil → 未检查(不是 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 4446
@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("推送失败"))
}
}