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

316 lines
10 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 Foundation
import SessionCore
import SwiftTerm
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-33 · ios-completion §4 RED 118
///
/// 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. 13
@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. 49
@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. /1014
@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. 1516
@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 1718Accept
/// `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)
}
}