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.
316 lines
10 KiB
Swift
316 lines
10 KiB
Swift
import Foundation
|
||
import SessionCore
|
||
import SwiftTerm
|
||
import TestSupport
|
||
import Testing
|
||
import UIKit
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-33 · 终端内搜索(ios-completion §4 RED 清单 1–18)。
|
||
///
|
||
/// 契约来源:SwiftTerm 1.13.0 自带的搜索 API(`findNext`/`findPrevious`/
|
||
/// `clearSearch`,`SearchOptions` 默认与 xterm.js search addon 一致)+ web 参照
|
||
/// `public/search.ts`(Enter=next / Shift+Enter=prev / Esc=关闭并 clear)。
|
||
///
|
||
/// 铁律:搜索是**纯读** —— 整个流程不得产生一个 PTY 字节(byte-shuttle 不变式)。
|
||
@MainActor
|
||
@Suite("TerminalSearch (T-iOS-33)")
|
||
struct TerminalSearchTests {
|
||
// MARK: - Fakes
|
||
|
||
/// 记录调用序列的搜索引擎替身:命中与否由测试注入。
|
||
@MainActor
|
||
private final class FakeSearcher: TerminalSearching {
|
||
enum Call: Equatable {
|
||
case next(String)
|
||
case previous(String)
|
||
case clear
|
||
}
|
||
|
||
private(set) var calls: [Call] = []
|
||
var hitResult = true
|
||
|
||
func searchNext(term: String) -> Bool {
|
||
calls = calls + [.next(term)]
|
||
return hitResult
|
||
}
|
||
|
||
func searchPrevious(term: String) -> Bool {
|
||
calls = calls + [.previous(term)]
|
||
return hitResult
|
||
}
|
||
|
||
func searchClear() {
|
||
calls = calls + [.clear]
|
||
}
|
||
}
|
||
|
||
private func model(_ searcher: FakeSearcher) -> TerminalSearchModel {
|
||
let model = TerminalSearchModel()
|
||
model.attach(searcher)
|
||
return model
|
||
}
|
||
|
||
// MARK: - A. 查询提交策略(1–3)
|
||
|
||
@Test("1 · 空串不可提交,且零引擎调用")
|
||
func emptyQueryIsNotSubmittable() {
|
||
#expect(!TerminalSearchQuery.isSubmittable(""))
|
||
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = ""
|
||
model.find(.next)
|
||
|
||
#expect(searcher.calls.isEmpty)
|
||
#expect(model.outcome == .idle)
|
||
}
|
||
|
||
@Test("2 · 单个空格可提交(镜像 web:空格是合法搜索词,绝不 trim 掉)")
|
||
func singleSpaceIsSubmittable() {
|
||
#expect(TerminalSearchQuery.isSubmittable(" "))
|
||
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = " "
|
||
model.find(.next)
|
||
|
||
#expect(searcher.calls == [.next(" ")])
|
||
}
|
||
|
||
@Test("3 · 查询词逐字符原样下传(不 trim、不改大小写)")
|
||
func queryIsPassedThroughVerbatim() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = " Foo "
|
||
model.find(.next)
|
||
|
||
#expect(searcher.calls == [.next(" Foo ")])
|
||
}
|
||
|
||
// MARK: - B. 方向与引擎调用(4–9)
|
||
|
||
@Test("4 · find(.next) → 恰一次 searchNext,零 searchPrevious")
|
||
func findNextCallsNextOnly() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = "claude"
|
||
model.find(.next)
|
||
|
||
#expect(searcher.calls == [.next("claude")])
|
||
}
|
||
|
||
@Test("5 · find(.previous) → 恰一次 searchPrevious,零 searchNext")
|
||
func findPreviousCallsPreviousOnly() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = "claude"
|
||
model.find(.previous)
|
||
|
||
#expect(searcher.calls == [.previous("claude")])
|
||
}
|
||
|
||
@Test("6 · 引擎命中 → outcome == .found")
|
||
func hitProducesFoundOutcome() {
|
||
let searcher = FakeSearcher()
|
||
searcher.hitResult = true
|
||
let model = model(searcher)
|
||
model.query = "claude"
|
||
model.find(.next)
|
||
|
||
#expect(model.outcome == .found)
|
||
}
|
||
|
||
@Test("7 · 引擎未命中 → outcome == .notFound,且有非空文案")
|
||
func missProducesNotFoundOutcomeWithCopy() {
|
||
let searcher = FakeSearcher()
|
||
searcher.hitResult = false
|
||
let model = model(searcher)
|
||
model.query = "zzz"
|
||
model.find(.next)
|
||
|
||
#expect(model.outcome == .notFound)
|
||
let status = model.statusText
|
||
#expect(status != nil)
|
||
#expect(!(status ?? "").isEmpty)
|
||
}
|
||
|
||
@Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)")
|
||
func repeatedFindHitsEngineEachTime() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = "x"
|
||
model.find(.next)
|
||
model.find(.next)
|
||
model.find(.next)
|
||
|
||
#expect(searcher.calls == [.next("x"), .next("x"), .next("x")])
|
||
}
|
||
|
||
@Test("9 · 未 attach 引擎 → 不崩,outcome 保持 .idle")
|
||
func noSearcherAttachedIsSafe() {
|
||
let model = TerminalSearchModel()
|
||
model.query = "claude"
|
||
model.find(.next)
|
||
|
||
#expect(model.outcome == .idle)
|
||
#expect(model.statusText == nil)
|
||
}
|
||
|
||
// MARK: - C. 打开/关闭生命周期(10–14)
|
||
|
||
@Test("10 · 初始 isPresented == false 且 outcome == .idle")
|
||
func initialStateIsClosedAndIdle() {
|
||
let model = TerminalSearchModel()
|
||
|
||
#expect(!model.isPresented)
|
||
#expect(model.outcome == .idle)
|
||
}
|
||
|
||
@Test("11 · present()/dismiss() 切换 isPresented")
|
||
func presentAndDismissTogglePresentation() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
|
||
model.present()
|
||
#expect(model.isPresented)
|
||
|
||
model.dismiss()
|
||
#expect(!model.isPresented)
|
||
}
|
||
|
||
@Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位")
|
||
func dismissClearsHighlightAndQuery() {
|
||
let searcher = FakeSearcher()
|
||
searcher.hitResult = false
|
||
let model = model(searcher)
|
||
model.present()
|
||
model.query = "zzz"
|
||
model.find(.next)
|
||
#expect(model.outcome == .notFound)
|
||
|
||
model.dismiss()
|
||
|
||
#expect(searcher.calls == [.next("zzz"), .clear])
|
||
#expect(model.query.isEmpty)
|
||
#expect(model.outcome == .idle)
|
||
}
|
||
|
||
@Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)")
|
||
func presentDoesNotTouchEngine() {
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.present()
|
||
|
||
#expect(searcher.calls.isEmpty)
|
||
}
|
||
|
||
@Test("14 · 改 query → outcome 复位 .idle(旧「无匹配」不挂新词)")
|
||
func editingQueryResetsOutcome() {
|
||
let searcher = FakeSearcher()
|
||
searcher.hitResult = false
|
||
let model = model(searcher)
|
||
model.query = "zzz"
|
||
model.find(.next)
|
||
#expect(model.outcome == .notFound)
|
||
|
||
model.query = "zz"
|
||
|
||
#expect(model.outcome == .idle)
|
||
#expect(model.statusText == nil)
|
||
}
|
||
|
||
// MARK: - D. 不变式:搜索是纯读(15–16)
|
||
|
||
@Test("15 · 整个搜索流程后零 PTY 字节(byte-shuttle 不变式)")
|
||
func searchNeverWritesToThePty() async throws {
|
||
// Arrange:真 SessionEngine over FakeTransport + 真 TerminalViewModel。
|
||
let transport = FakeTransport()
|
||
let clock = FakeClock()
|
||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let engine = SessionEngine(
|
||
transport: transport, clock: clock, endpoint: endpoint,
|
||
eventsSource: { _ in [] }
|
||
)
|
||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||
terminalViewModel.start()
|
||
|
||
// Act:开面板 → 搜前后 → 关面板。
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.present()
|
||
model.query = "claude"
|
||
model.find(.next)
|
||
model.find(.previous)
|
||
model.dismiss()
|
||
|
||
// Assert:一个字节都没进 PTY。
|
||
#expect(terminalViewModel.forwardedSendCount == 0)
|
||
#expect(terminalViewModel.droppedReadOnlyInputCount == 0)
|
||
}
|
||
|
||
@Test("16 · 终端已退出(read-only)时搜索照常可用")
|
||
func searchWorksOnAnExitedTerminal() async throws {
|
||
// Arrange:把 VM 推到 .exited。
|
||
let transport = FakeTransport()
|
||
let clock = FakeClock()
|
||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let engine = SessionEngine(
|
||
transport: transport, clock: clock, endpoint: endpoint,
|
||
eventsSource: { _ in [] }
|
||
)
|
||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||
terminalViewModel.start()
|
||
await engine.open(sessionId: nil, cwd: nil)
|
||
await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#)
|
||
await terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||
#expect(terminalViewModel.isReadOnly)
|
||
|
||
// Act
|
||
let searcher = FakeSearcher()
|
||
let model = model(searcher)
|
||
model.query = "claude"
|
||
model.find(.next)
|
||
|
||
// Assert:搜索是纯读,退出后仍然可用。
|
||
#expect(searcher.calls == [.next("claude")])
|
||
#expect(model.outcome == .found)
|
||
}
|
||
|
||
// MARK: - E. SwiftTerm 绑定(17–18,Accept:命中并高亮)
|
||
|
||
/// 真 `KeyCommandTerminalView`(非替身):喂一段输出,再用 `TerminalSearching`
|
||
/// 的三个方法打 SwiftTerm 自带搜索,命中即选区高亮。
|
||
@Test("17 · 真 SwiftTerm view:命中返回 true 并高亮选区;未命中返回 false")
|
||
func realTerminalViewSearchHitsAndHighlights() {
|
||
let terminal = KeyCommandTerminalView(
|
||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||
)
|
||
terminal.feed(text: "claude code cockpit\r\n")
|
||
|
||
let searcher: any TerminalSearching = terminal
|
||
#expect(searcher.searchNext(term: "cockpit"))
|
||
#expect(terminal.hasActiveSelection)
|
||
|
||
#expect(!searcher.searchNext(term: "nonexistent-needle"))
|
||
}
|
||
|
||
@Test("18 · searchClear() 清掉高亮")
|
||
func clearRemovesHighlight() {
|
||
let terminal = KeyCommandTerminalView(
|
||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||
)
|
||
terminal.feed(text: "claude code cockpit\r\n")
|
||
let searcher: any TerminalSearching = terminal
|
||
#expect(searcher.searchNext(term: "cockpit"))
|
||
#expect(terminal.hasActiveSelection)
|
||
|
||
searcher.searchClear()
|
||
|
||
#expect(!terminal.hasActiveSelection)
|
||
}
|
||
}
|