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

188 lines
6.5 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 · `claude --resume <id>` `GET /sessions` App
///
/// RED `docs/plans/ios-completion.md` §4E 2835 id
/// **** PTY
/// web `public/tabs.ts:918`
@MainActor
@Suite("ResumeHistoryViewModel")
struct ResumeHistoryViewModelTests {
private static let projectPath = "/repos/web-terminal"
private nonisolated static func session(
id: String = "3f2b1c4d-0000-4000-8000-000000000001",
cwd: String,
mtimeMs: Double = 1_785_390_645_813.5
) -> HistorySession {
HistorySession(
id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale"
)
}
// MARK: - 2830
@Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree")
func keepsOnlySessionsInsideProject() {
let inside = Self.session(id: "a1", cwd: Self.projectPath)
let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x")
let outside = Self.session(id: "a3", cwd: "/repos/other")
let candidates = ResumeHistoryViewModel.candidates(
from: [inside, nested, outside], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["a1", "a2"])
}
@Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal")
func siblingPrefixIsNotInside() {
let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old")
let candidates = ResumeHistoryViewModel.candidates(
from: [sibling], projectPath: Self.projectPath
)
#expect(candidates.isEmpty)
}
@Test("服务器顺序mtime 新→旧)原样保留,客户端不重排")
func serverOrderIsPreserved() {
let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000)
let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000)
let candidates = ResumeHistoryViewModel.candidates(
from: [newer, older], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["new", "old"])
}
@Test("cwd 非绝对路径 → 不可恢复Validation.isAbsoluteCwd 纪律)")
func relativeCwdIsFilteredOut() {
let relative = Self.session(id: "a1", cwd: "repos/web-terminal")
let candidates = ResumeHistoryViewModel.candidates(
from: [relative], projectPath: "repos/web-terminal"
)
#expect(candidates.isEmpty)
}
// MARK: - phase3132
@Test("空结果 → .empty不是 .failed服务器无历史时正常回 []")
func emptyListIsNotFailure() async {
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] })
await vm.load()
#expect(vm.phase == .empty)
}
@Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty")
func allFilteredOutIsEmpty() async {
let vm = ResumeHistoryViewModel(
projectPath: Self.projectPath,
fetch: { [Self.session(id: "a1", cwd: "/repos/other")] }
)
await vm.load()
#expect(vm.phase == .empty)
}
@Test("加载失败 → .failed可重试重试成功 → .loaded")
func failureIsRetryable() async {
let flag = ResumeFailOnceFlag()
let payload = Self.session(id: "a1", cwd: Self.projectPath)
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: {
if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable }
return [payload]
})
await vm.load()
guard case .failed = vm.phase else {
Issue.record("应为 .failed实际 \(vm.phase)")
return
}
await vm.load()
#expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates(
from: [payload], projectPath: Self.projectPath
)))
}
// MARK: - 3334
@Test(
"危险 id 绝不拼进 PTY 命令行(注入白名单)",
arguments: [
"abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'",
"abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "",
]
)
func dangerousIdsAreRejected(sessionId: String) {
#expect(
ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil,
"\(sessionId) 不应被接受"
)
}
@Test("超长 id> 128拒绝")
func overlongIdRejected() {
let long = String(repeating: "a", count: 129)
#expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil)
}
@Test("合法 id → `claude --resume <id>` 且以 \\r0x0D结尾绝不是 \\n")
func validIdBuildsCarriageReturnCommand() throws {
let id = "3f2b1c4d-0000-4000-8000-000000000001"
let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id))
#expect(input == "claude --resume \(id)\r")
#expect(input.hasSuffix("\r"))
#expect(!input.contains("\n"))
}
@Test("非法 id 的历史行仍显示,但 canResume == false不提供恢复按钮")
func rowWithBadIdIsNotResumable() {
let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath)
let candidates = ResumeHistoryViewModel.candidates(
from: [bad], projectPath: Self.projectPath
)
#expect(candidates.count == 1)
#expect(!candidates[0].canResume)
}
@Test("合法行 canResume == true且携带会话自己的 cwdworktree 会话回到 worktree")
func resumableRowCarriesItsOwnCwd() throws {
let nestedCwd = Self.projectPath + "/.claude/worktrees/x"
let session = Self.session(id: "a1", cwd: nestedCwd)
let candidate = try #require(ResumeHistoryViewModel.candidates(
from: [session], projectPath: Self.projectPath
).first)
#expect(candidate.canResume)
#expect(candidate.cwd == nestedCwd)
}
}
/// @Sendable fetch actor
private actor ResumeFailOnceFlag {
private var shouldFail = true
func consumeShouldFail() -> Bool {
defer { shouldFail = false }
return shouldFail
}
}