feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening
T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists. Fixes (1 MED + 3 LOW, all closed): - WebTermUITests: the single scripted happy path (manual-entry pair → list → attach → KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side behavior==allow assertion). Green twice locally (fresh + already-paired branches). - ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO from app-tests leg (unsigned hosts get -34018 from the real keychain — measured) - URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes) - TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only) Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/ untouched; P0 complete (19/19 tasks)
This commit is contained in:
109
.github/workflows/ios.yml
vendored
109
.github/workflows/ios.yml
vendored
@@ -71,10 +71,14 @@ jobs:
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||
# handoff finding, verified locally in the ui-test leg runs.)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
CODE_SIGNING_ALLOWED=NO test
|
||||
test
|
||||
|
||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
|
||||
@@ -92,3 +96,106 @@ jobs:
|
||||
run: npm ci
|
||||
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
|
||||
run: swift test --package-path ios/IntegrationTests
|
||||
|
||||
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
|
||||
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
|
||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||
ui-test:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: Start web-terminal server on loopback
|
||||
run: |
|
||||
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
|
||||
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
|
||||
echo $! > uitest-server.pid
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "server did not come up on 127.0.0.1:3217" >&2
|
||||
cat uitest-server.log >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
# The 输入 step taps the KeyBar, which is the terminal's
|
||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||
# strips the prefix and injects <VAR> into the test-runner process);
|
||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||
# never reaches the runner (verified empirically, 2026-07-05).
|
||||
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
|
||||
# the data-protection keychain rejects every SecItem call (-34018) and
|
||||
# pairing dies at "保存到本机失败" (verified empirically, run 6).
|
||||
# Simulator builds sign locally without any certificate.
|
||||
env:
|
||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
||||
- name: Server log + shutdown
|
||||
if: always()
|
||||
run: |
|
||||
cat uitest-server.log || true
|
||||
kill "$(cat uitest-server.pid)" 2>/dev/null || true
|
||||
|
||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
||||
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
||||
# Xcode 16.x can build against it. Local machines are NOT expected to
|
||||
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
|
||||
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
||||
# fail the job (no silent pass).
|
||||
ios17-floor-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3 (build toolchain)
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
||||
id: sim17
|
||||
run: |
|
||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
||||
if [ -z "$runtime" ]; then
|
||||
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
|
||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||
echo "created $udid with $runtime"
|
||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||
if: steps.sim17.outputs.runtime != ''
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🚧 iOS 客户端 P0 实施(进行中 — 2026-07-04,分支 `feat/ios-client`)
|
||||
### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
|
||||
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
|
||||
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:
|
||||
- **T-iOS-1 脚手架**: `ios/` 全结构(project.yml/XcodeGen、5 包空壳、WebTermApp 空窗、`.github/workflows/ios.yml`);Info.plist 经 plutil 核对 = §5.2 逐字(五段 CIDR,**全程零 NSAllowsArbitraryLoads**);bundle id `com.yaojia.webterm`。**偏差**: "default MainActor isolation" 属 Swift 6.2+,本机 6.1 → Swift 6 语言模式 + strict concurrency + 显式 @MainActor 替代(已注 project.yml)。**环境事实**: 本机 Xcode 16.3 原本无 iOS 模拟器 runtime → `xcodebuild -downloadPlatform iOS` 装 iOS 18.4(22E238)后模拟器构建绿。
|
||||
@@ -56,7 +56,13 @@
|
||||
- **环境硬墙(新发现,已记 harness)**: repo 在 `~/Documents`(TCC 保护)时,模拟器 App 语境 posix_spawn 的 node 读 repo 文件被 tccd 无限阻塞(sample 实测卡 `uv_cwd→__open_nocancel`;平台二进制豁免)→ SimServerHarness 双模式:A 自举(CI/非 TCC 路径)/B `WEBTERM_SERVER_URL` 外部注入;TCC 下 0.001s 快速失败+指引。**本机跑法**: 起服务器后 `xcrun simctl spawn booted launchctl setenv WEBTERM_SERVER_URL …` 再 xcodebuild test(命令行 TEST_RUNNER_ 前缀实测传不进宿主 App 测试进程);裸跑 smoke 红=有意显式失败非回归。
|
||||
- **偏差闭合(orchestrator 代 T-iOS-11 owner 修)**: W4 曾记「alive-engine `.active` 未调 notifyForegrounded(dims:)——W3 无 dims 钩子」→ TerminalViewModel 增 `lastSentDims`(仅记有效 resize,invalid 不覆盖,有测试)+ controller `.active` 分支接 `notifyForegrounded(dims:)`(无有效 dims 时跳过,SwiftTerm 首次布局兜底)。**76 App 测全绿**(75+1)。
|
||||
- **verify(独立 agent,8/8)**: 214 包测+76 App 测+10 集成;spike 删净;遮罩规则/生命周期双向 close 路径/组装安全(G 全走 APIClient、零 ad-hoc URLSession、零 ArbitraryLoads、Origin 零手拼)逐项在源码核实;Owns 零越界。
|
||||
- 待续: W5(T-iOS-18 F 走查 ∥ T-iOS-19 安全核对,report-only)→ P0 收官。真机项(键盘/IME/扫码/切换器遮罩目检/ntfy 端到端)全部 DEFERRED 至有真机时由用户执行 T-iOS-18 清单。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||
- **[x] W5 验收 + findings 修复(2 reviewer ∥ + 1 fix builder;均 PASS_WITH_FINDINGS→已闭合)**:
|
||||
- **T-iOS-18 F 走查**: 全部机器可执行项本轮亲自重跑(集成 10/App 76/包 214 + 模拟器冷启动·后台·重启截屏);F-iOS-1..13 逐条 EXECUTED/EVIDENCED(指认测试名)/DEFERRED(真机)(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端,附手工步骤清单)。
|
||||
- **T-iOS-19 安全核对**: 对产物审——Origin 写入点全树恰两处且单点派生;G/RO 分界;**拆构建产物**验五段 CIDR+零 ArbitraryLoads+双 usage description;Keychain 属性;P0 不该在的面(deep link/麦克风)确认缺席;残余风险(isCaptured 跳过/ws 明文模型/epoch 仅客户端)记录完整。
|
||||
- **findings(0 CRITICAL/HIGH; 1 MED+3 LOW,全部闭合)**: ①MED §9 XCUITest happy path 缺失 → orchestrator 建 WebTermUITests target/scheme,fix agent 落地唯一用例:**配对(手输)→列表→attach→KeyBar ^L→注入 held gate(POST /hook/permission)→点 Approve→断言服务器真放行 behavior==allow**,服务器为 oracle(XCUITest 读不了 SwiftTerm 字形);幂等三分支(全新/已配对/空态)全被实走;**两次真实跑通**(439s/677s,EXIT=0)。②LOW CI 缺 iOS17 下限腿 → ios.yml 增 ios17-floor job(runtime 缺失大声跳过/存在必判,CI-only 本机未验)。③LOW URLSession.shared 磁盘缓存可落盘 preview 终端字节 → 生产 HTTPTransport 默认 .ephemeral。④LOW XcodeGen target 级默认覆盖 project 级 TARGETED_DEVICE_FAMILY(实际打包 [1,2]) → 移 target 级,产物已验 [1]。
|
||||
- **XCUITest 实证发现(已记 ios.yml/用例注释)**: `TEST_RUNNER_` env 只在 xcodebuild **进程环境**里才透传 runner(命令行 KEY=VALUE 是 build setting 到不了);**`CODE_SIGNING_ALLOWED=NO` 打断真 Keychain(-34018)**→ 顺手修掉既有 app-tests 腿同 flag(否则 KeychainHostStoreLiveTests 在 GH runner 必红);XCUITest typeText 够不着 SwiftTerm(无 AX 键盘焦点)→ 输入断言走 KeyBar ^L+清屏应答;SwiftTerm 光标闪烁烧 XCUITest 60s idle-wait/事件(单跑 7-11min,已压到 3 次终端交互)。
|
||||
- **遗留观察(report-only,非阻塞)**: 配对探针偶发残留 clientCount=0 会话(killProbeSession 竞态,run 8 一例)——归 T-iOS-8 owner 复核;真 GH Actions 首跑待推送后确认(ui-test 腿/ios17 runtime 镜像现状)。
|
||||
- **P0 收官统计**: 306 项自动化(214 包测 + 76 App 测 + 10 集成 + 1 XCUITest[计 1,参数化更多]) 全绿;覆盖率 own-Sources SessionCore 96.7%/HostRegistry 88.1%/APIClient 98.1%/WireProtocol 100%;19/19 任务完成(全部偏差有日志);服务器 src/ public/ **零改动**。**待用户**: ①真机走查清单(T-iOS-18 报告内);②推送分支跑首轮 GH Actions;③P1 开工前拍板:Apple 付费账号(APNs/TestFlight)与 bundle id/App 名(现 com.yaojia.webterm/WebTerm)。P1 首批 = T-iOS-20(APNs server) ∥ T-iOS-37(lastOutputAt) ∥ T-iOS-38(APIClient P1 契约)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||
|
||||
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
|
||||
**多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。
|
||||
|
||||
@@ -71,6 +71,7 @@ struct GateBanner: View {
|
||||
Button(spec.label) {
|
||||
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||
}
|
||||
.accessibilityIdentifier("gate.decision.\(spec.affordance)")
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.tint(tint(for: spec.affordance))
|
||||
|
||||
@@ -60,9 +60,11 @@ struct PairingScreen: View {
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
}
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Section {
|
||||
@@ -155,6 +157,7 @@ private struct ConfirmHostView: View {
|
||||
Button(ScreenCopy.connect) {
|
||||
Task { await viewModel.confirmConnect() }
|
||||
}
|
||||
.accessibilityIdentifier("pairing.confirmButton")
|
||||
Button(ScreenCopy.cancel, role: .cancel) {
|
||||
viewModel.cancel()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ struct SessionListScreen: View {
|
||||
} label: {
|
||||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
ForEach(viewModel.rows) { row in
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
@@ -100,6 +101,7 @@ struct SessionListScreen: View {
|
||||
} actions: {
|
||||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +133,7 @@ struct SessionListScreen: View {
|
||||
systemImage: "desktopcomputer"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("sessions.hostMenu")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ import WireProtocol
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
|
||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
||||
/// that may contain printed secrets — and `.shared`'s default URLCache
|
||||
/// writes responses to disk. Ephemeral keeps them memory-only, matching
|
||||
/// the WS transport and the privacy-shade posture.
|
||||
init(session: URLSession = URLSession(configuration: .ephemeral)) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
|
||||
453
ios/App/WebTermUITests/HappyPathUITests.swift
Normal file
453
ios/App/WebTermUITests/HappyPathUITests.swift
Normal file
@@ -0,0 +1,453 @@
|
||||
import XCTest
|
||||
|
||||
/// W5 · The ONE scripted XCUITest happy path (PLAN_IOS_CLIENT §9, fragile-
|
||||
/// surface rule): 配对(手输) → 会话列表 → attach → 输入 → gate approve —
|
||||
/// against a REAL web-terminal server on loopback.
|
||||
///
|
||||
/// Contract with the harness:
|
||||
/// - The server URL arrives in the RUNNER process env as `WEBTERM_SERVER_URL`
|
||||
/// (xcodebuild delivers it via the `TEST_RUNNER_` prefix:
|
||||
/// `TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>`). Missing env =
|
||||
/// explicit FAILURE with instructions, never a silent skip (a green-by-skip
|
||||
/// CI leg would be worthless).
|
||||
/// - The runner process makes its OWN URLSession calls to the server for
|
||||
/// server-side assertions (attach visible in /live-sessions, typed marker in
|
||||
/// /live-sessions/:id/preview, held POST /hook/permission resolving to
|
||||
/// allow) — XCUITest cannot read SwiftTerm glyphs, the server is the oracle.
|
||||
///
|
||||
/// Idempotency (documented decision): the app persists hosts in the REAL
|
||||
/// Keychain and a UI-test runner cannot uninstall the app, so the flow is
|
||||
/// TOLERANT of prior state instead of resetting it:
|
||||
/// - fresh install → first-run PairingScreen path;
|
||||
/// - host(s) already paired → pair the CURRENT server via the 配对新主机
|
||||
/// sheet, then select the just-added host (last menu row with the loopback
|
||||
/// label — `HostStore.upsert` appends, so ours is last).
|
||||
/// CI simulators are always fresh, so CI exercises the first-run branch; local
|
||||
/// re-runs exercise the tolerant branch.
|
||||
///
|
||||
/// Waits: every UI wait is `waitForExistence` (predicate-based) with a
|
||||
/// deadline; server polling is bounded loops with 1 s pauses — no fixed sleep
|
||||
/// exceeds 2 s. BUDGET NOTE (measured deviation from the ~120 s aspiration):
|
||||
/// SwiftTerm's caret blink keeps Core Animation permanently non-quiescent, so
|
||||
/// EVERY XCUITest event on the terminal screen first burns XCUITest's fixed
|
||||
/// 60 s "wait for app to idle" timeout ("App animations complete notification
|
||||
/// not received" in the log). Interactions there are minimized to three
|
||||
/// (focus tap · KeyBar ^L · gate approve) → the whole test runs ~4-5 min.
|
||||
///
|
||||
/// `@MainActor`: the Xcode 16.3 SDK isolates XCUIApplication/XCUIElement to
|
||||
/// the main actor; XCTest runs test methods on the main thread, so class-wide
|
||||
/// isolation is the correct spelling under Swift 6 strict concurrency. The
|
||||
/// bounded semaphore waits below block only the RUNNER's main thread — the
|
||||
/// app under test is a separate process and keeps running.
|
||||
@MainActor
|
||||
final class HappyPathUITests: XCTestCase {
|
||||
|
||||
// MARK: - Deadlines (seconds; total budget ~120 s)
|
||||
|
||||
private enum Deadline {
|
||||
static let launchSurface: TimeInterval = 30
|
||||
static let ui: TimeInterval = 20
|
||||
static let http: TimeInterval = 10
|
||||
static let attach: TimeInterval = 30
|
||||
static let marker: TimeInterval = 30
|
||||
static let gateBanner: TimeInterval = 25
|
||||
static let heldDecision: TimeInterval = 30
|
||||
static let pollPause: TimeInterval = 1
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case surfaceNotFound
|
||||
case pollTimedOut(String)
|
||||
case httpFailed(String)
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
// MARK: - The one happy path
|
||||
|
||||
func testHappyPath_pairListAttachTypeGateApprove() throws {
|
||||
let server = try serverBaseURL()
|
||||
|
||||
// Precondition: server reachable (fail fast with an actionable message).
|
||||
let (probeStatus, _) = try httpGET(server.appendingPathComponent("live-sessions"))
|
||||
XCTAssertEqual(
|
||||
probeStatus, 200,
|
||||
"web-terminal server not reachable at \(server) — start it and pass "
|
||||
+ "TEST_RUNNER_WEBTERM_SERVER_URL to xcodebuild"
|
||||
)
|
||||
|
||||
// 1 · Launch and pair (tolerant of pre-existing Keychain hosts).
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
try pairCurrentServer(app, server: server)
|
||||
|
||||
// 2 · Session list → + New session.
|
||||
let newButton = app.buttons["sessions.newButton"].firstMatch
|
||||
XCTAssertTrue(
|
||||
newButton.waitForExistence(timeout: Deadline.ui),
|
||||
"session list 新建会话 button not found after pairing"
|
||||
)
|
||||
let sessionsBefore = try liveSessionIds(server)
|
||||
newButton.tap()
|
||||
|
||||
// 3 · attach — server-side oracle: a NEW session with a client attached.
|
||||
let sessionId = try pollForAttachedSession(server, excluding: sessionsBefore)
|
||||
|
||||
// 4 · 输入 — via the KeyBar, asserted server-side.
|
||||
//
|
||||
// WHY NOT typeText (verified empirically, run 7): XCUITest's typeText
|
||||
// requires an AX element with hasKeyboardFocus == 1, and SwiftTerm's
|
||||
// TerminalView never exposes one — the runner stalls hunting for
|
||||
// focus and no character ever reaches the PTY. The KeyBar is a plain
|
||||
// UIButton row (inputAccessoryView) needing no keyboard focus, and it
|
||||
// exercises the SAME input pipeline as typed text: KeyByteMap →
|
||||
// TerminalViewModel.send(key:) → engine → WS input frame → PTY.
|
||||
// Oracle: ^L (0x0C) makes bash-readline answer clear-screen
|
||||
// ESC[H ESC[2J — bytes that appear in the ring buffer ONLY when the
|
||||
// shell received our input (resize redraws are \r ESC[K, no overlap).
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
XCTAssertTrue(
|
||||
app.keyboards.firstMatch.waitForExistence(timeout: Deadline.ui),
|
||||
"software keyboard (with the KeyBar accessory) never appeared — "
|
||||
+ "disable the simulator's hardware keyboard if testing locally"
|
||||
)
|
||||
let ctrlLButton = app.buttons["Ctrl+L — redraw screen"].firstMatch
|
||||
XCTAssertTrue(
|
||||
ctrlLButton.waitForExistence(timeout: Deadline.ui),
|
||||
"KeyBar ^L button not found above the keyboard"
|
||||
)
|
||||
ctrlLButton.tap()
|
||||
try pollPreview(server, sessionId: sessionId, contains: "\u{1B}[H\u{1B}[2J")
|
||||
|
||||
// 5 · gate approve — hold a PermissionRequest exactly like a Claude
|
||||
// Code hook would (loopback POST, X-Webterm-Session header), approve
|
||||
// from the banner, and assert the HELD response resolves to allow.
|
||||
let held = startHeldPermissionRequest(server, sessionId: sessionId, tool: "UITestTool")
|
||||
let approveButton = app.buttons["gate.decision.approve"].firstMatch
|
||||
XCTAssertTrue(
|
||||
approveButton.waitForExistence(timeout: Deadline.gateBanner),
|
||||
"GateBanner approve button never appeared for the held permission"
|
||||
)
|
||||
approveButton.tap()
|
||||
|
||||
guard let result = held.waitForResult(timeout: Deadline.heldDecision) else {
|
||||
XCTFail("held POST /hook/permission did not resolve within \(Deadline.heldDecision)s of approving")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(result.status, 200, "held /hook/permission answered HTTP \(String(describing: result.status))")
|
||||
let behavior = Self.decisionBehavior(in: result.data)
|
||||
XCTAssertEqual(
|
||||
behavior, "allow",
|
||||
"held permission resolved to \(behavior ?? "<no decision>") — expected allow "
|
||||
+ "(body: \(String(data: result.data ?? Data(), encoding: .utf8) ?? "<non-utf8>"))"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pairing (tolerant two-branch flow)
|
||||
|
||||
private func serverBaseURL() throws -> URL {
|
||||
let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
|
||||
guard let raw, let url = URL(string: raw), url.host != nil else {
|
||||
XCTFail(
|
||||
"WEBTERM_SERVER_URL missing/invalid in the runner env. Run: "
|
||||
+ "xcodebuild -scheme WebTermUITests "
|
||||
+ "TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port> test "
|
||||
+ "(with the server running on that port)."
|
||||
)
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Waits for whichever entry surface the persisted state produced, then
|
||||
/// drives pairing so the CURRENT server ends up as the ACTIVE host.
|
||||
private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
|
||||
let urlField = app.textFields["pairing.urlField"].firstMatch
|
||||
let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
|
||||
let emptyAddHost = app.buttons["配对新主机"].firstMatch
|
||||
|
||||
let deadline = Date().addingTimeInterval(Deadline.launchSurface)
|
||||
while Date() < deadline {
|
||||
if urlField.exists {
|
||||
// Branch A — fresh install: first-run PairingScreen (CI path).
|
||||
try fillPairingForm(app, server: server)
|
||||
return
|
||||
}
|
||||
if hostMenu.exists || emptyAddHost.exists {
|
||||
// Branch B — prior state: add the current server via the sheet.
|
||||
if hostMenu.exists {
|
||||
hostMenu.tap()
|
||||
}
|
||||
XCTAssertTrue(
|
||||
emptyAddHost.waitForExistence(timeout: Deadline.ui),
|
||||
"配对新主机 entry not found on the session list"
|
||||
)
|
||||
emptyAddHost.tap()
|
||||
try fillPairingForm(app, server: server)
|
||||
try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
XCTFail("neither PairingScreen nor session list appeared within \(Deadline.launchSurface)s")
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
|
||||
/// Types the URL, submits, and confirms (loopback → §5.4 no-warning tier,
|
||||
/// so the confirm page has no blocking acknowledgement).
|
||||
private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
|
||||
let urlField = app.textFields["pairing.urlField"].firstMatch
|
||||
XCTAssertTrue(
|
||||
urlField.waitForExistence(timeout: Deadline.ui),
|
||||
"pairing URL field not found"
|
||||
)
|
||||
urlField.tap()
|
||||
urlField.typeText(server.absoluteString)
|
||||
app.buttons["pairing.submitButton"].firstMatch.tap()
|
||||
|
||||
let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
|
||||
XCTAssertTrue(
|
||||
confirmButton.waitForExistence(timeout: Deadline.ui),
|
||||
"pairing confirm page (连接) not reached — input rejected? "
|
||||
+ "(on-screen: \(visibleStaticTexts(app)))"
|
||||
)
|
||||
// Guard against typeText mangling: the confirm page must display the
|
||||
// EXACT parsed origin (single-point derivation shows what will probe).
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[expectedOrigin(server)].firstMatch.exists,
|
||||
"confirm page does not show \(expectedOrigin(server)) — the typed "
|
||||
+ "URL was mangled (on-screen: \(visibleStaticTexts(app)))"
|
||||
)
|
||||
confirmButton.tap()
|
||||
// Probe runs (GET /live-sessions + a WS round trip). Wait for the
|
||||
// WHOLE pairing surface to leave — its navigation bar (配对主机)
|
||||
// covers the confirm page, the transient 已配对 page AND the sheet's
|
||||
// dismissal animation (waiting only for the confirm button raced the
|
||||
// sheet and tapped an occluded toolbar — run-5 hit point {-1,-1}).
|
||||
// A probe failure shows the FailureView (重试) — fail FAST with its
|
||||
// on-screen copy.
|
||||
let pairingNavBar = app.navigationBars["配对主机"].firstMatch
|
||||
let retryButton = app.buttons["重试"].firstMatch
|
||||
let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
|
||||
while pairingNavBar.exists {
|
||||
if retryButton.exists {
|
||||
XCTFail("pairing probe FAILED — on-screen: \(visibleStaticTexts(app))")
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
if Date() >= resolveDeadline {
|
||||
XCTFail("pairing did not complete within \(Deadline.ui)s — "
|
||||
+ "on-screen: \(visibleStaticTexts(app))")
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// `scheme://host:port` exactly as HostEndpoint.originHeader derives it.
|
||||
private func expectedOrigin(_ server: URL) -> String {
|
||||
let scheme = server.scheme ?? "http"
|
||||
let host = server.host ?? ""
|
||||
let port = server.port.map { ":\($0)" } ?? ""
|
||||
return "\(scheme)://\(host)\(port)"
|
||||
}
|
||||
|
||||
/// First dozen visible static texts — failure-message diagnostics.
|
||||
private func visibleStaticTexts(_ app: XCUIApplication) -> String {
|
||||
app.staticTexts.allElementsBoundByIndex
|
||||
.prefix(12)
|
||||
.map(\.label)
|
||||
.joined(separator: " | ")
|
||||
}
|
||||
|
||||
/// Branch B only: the add-host sheet upserts (appends) the new host but
|
||||
/// keeps the previously active one. The just-added host is the LAST menu
|
||||
/// row bearing the loopback host label; tapping it selects it (or no-ops
|
||||
/// if it is already active — both are fine).
|
||||
private func selectJustPairedHost(
|
||||
_ app: XCUIApplication, server: URL, hostMenu: XCUIElement
|
||||
) throws {
|
||||
let hostLabel = server.host ?? "127.0.0.1"
|
||||
XCTAssertTrue(
|
||||
hostMenu.waitForExistence(timeout: Deadline.ui),
|
||||
"host menu not visible after add-host sheet dismissed"
|
||||
)
|
||||
// The sheet's dismissal animation can leave the menu present-but-not-
|
||||
// hittable for a beat — tapping then dies with kAXErrorCannotComplete.
|
||||
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
|
||||
while !hostMenu.isHittable {
|
||||
if Date() >= hittableDeadline {
|
||||
XCTFail("host menu never became hittable after the add-host sheet")
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
hostMenu.tap()
|
||||
let rows = app.buttons.matching(
|
||||
NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
|
||||
)
|
||||
let rowDeadline = Date().addingTimeInterval(Deadline.ui)
|
||||
while rows.count == 0, Date() < rowDeadline {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
let allRows = rows.allElementsBoundByIndex
|
||||
guard let lastRow = allRows.last else {
|
||||
XCTFail("no host menu row labelled \(hostLabel) after pairing it")
|
||||
throw TestError.surfaceNotFound
|
||||
}
|
||||
lastRow.tap()
|
||||
}
|
||||
|
||||
// MARK: - Server oracles (runner-side URLSession)
|
||||
|
||||
private func liveSessionIds(_ server: URL) throws -> Set<String> {
|
||||
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
|
||||
guard status == 200, let data,
|
||||
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||
else {
|
||||
throw TestError.httpFailed("GET /live-sessions → \(status.map(String.init) ?? "no response")")
|
||||
}
|
||||
return Set(list.compactMap { $0["id"] as? String })
|
||||
}
|
||||
|
||||
/// Polls /live-sessions until a session NOT in `excluding` reports
|
||||
/// clientCount >= 1 (the app's WS attach) — returns its id.
|
||||
private func pollForAttachedSession(
|
||||
_ server: URL, excluding: Set<String>
|
||||
) throws -> String {
|
||||
let deadline = Date().addingTimeInterval(Deadline.attach)
|
||||
while Date() < deadline {
|
||||
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
|
||||
if status == 200, let data,
|
||||
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
||||
for entry in list {
|
||||
guard let id = entry["id"] as? String,
|
||||
!excluding.contains(id),
|
||||
let clientCount = entry["clientCount"] as? Int,
|
||||
clientCount >= 1
|
||||
else { continue }
|
||||
return id
|
||||
}
|
||||
}
|
||||
Thread.sleep(forTimeInterval: Deadline.pollPause)
|
||||
}
|
||||
XCTFail("no newly attached session appeared in /live-sessions within \(Deadline.attach)s")
|
||||
throw TestError.pollTimedOut("attach")
|
||||
}
|
||||
|
||||
/// Polls /live-sessions/:id/preview until the ring-buffer tail contains
|
||||
/// `marker` (the shell OUTPUT of the typed command).
|
||||
private func pollPreview(
|
||||
_ server: URL, sessionId: String, contains marker: String
|
||||
) throws {
|
||||
let url = server
|
||||
.appendingPathComponent("live-sessions")
|
||||
.appendingPathComponent(sessionId)
|
||||
.appendingPathComponent("preview")
|
||||
let deadline = Date().addingTimeInterval(Deadline.marker)
|
||||
while Date() < deadline {
|
||||
let (status, data) = try httpGET(url)
|
||||
if status == 200, let data,
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = obj["data"] as? String,
|
||||
text.contains(marker) {
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: Deadline.pollPause)
|
||||
}
|
||||
XCTFail("marker \(marker) never showed up in /live-sessions/\(sessionId)/preview within \(Deadline.marker)s")
|
||||
throw TestError.pollTimedOut("marker")
|
||||
}
|
||||
|
||||
// MARK: - Held permission (mimics the Claude Code PermissionRequest hook)
|
||||
|
||||
/// Fire-and-park POST /hook/permission: the server HOLDS the response until
|
||||
/// a client decides (or its 5-min default timeout). Returns a handle the
|
||||
/// test awaits AFTER tapping ✓ Approve.
|
||||
private func startHeldPermissionRequest(
|
||||
_ server: URL, sessionId: String, tool: String
|
||||
) -> HeldHTTPResponse {
|
||||
var request = URLRequest(url: server.appendingPathComponent("hook/permission"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(sessionId, forHTTPHeaderField: "X-Webterm-Session")
|
||||
request.timeoutInterval = 120
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"hook_event_name": "PermissionRequest",
|
||||
"tool_name": tool,
|
||||
])
|
||||
let held = HeldHTTPResponse()
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
held.complete(
|
||||
status: (response as? HTTPURLResponse)?.statusCode,
|
||||
data: data,
|
||||
errorDescription: error.map(String.init(describing:))
|
||||
)
|
||||
}
|
||||
task.resume()
|
||||
return held
|
||||
}
|
||||
|
||||
/// Extracts hookSpecificOutput.decision.behavior from the resolved hold.
|
||||
private static func decisionBehavior(in data: Data?) -> String? {
|
||||
guard let data,
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let output = obj["hookSpecificOutput"] as? [String: Any],
|
||||
let decision = output["decision"] as? [String: Any]
|
||||
else { return nil }
|
||||
return decision["behavior"] as? String
|
||||
}
|
||||
|
||||
// MARK: - Bounded synchronous HTTP (URLSession callbacks run off-thread)
|
||||
|
||||
private func httpGET(_ url: URL) throws -> (Int?, Data?) {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = Deadline.http
|
||||
let box = HeldHTTPResponse()
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
box.complete(
|
||||
status: (response as? HTTPURLResponse)?.statusCode,
|
||||
data: data,
|
||||
errorDescription: error.map(String.init(describing:))
|
||||
)
|
||||
}.resume()
|
||||
guard let result = box.waitForResult(timeout: Deadline.http + 2) else {
|
||||
throw TestError.httpFailed("GET \(url) timed out after \(Deadline.http + 2)s")
|
||||
}
|
||||
if let errorDescription = result.errorDescription {
|
||||
throw TestError.httpFailed("GET \(url) failed: \(errorDescription)")
|
||||
}
|
||||
return (result.status, result.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-protected completion box for URLSession callbacks (they arrive on the
|
||||
/// session's delegate queue; the test thread waits with a bounded semaphore).
|
||||
/// `@unchecked Sendable`: every mutable field is guarded by `lock`.
|
||||
private final class HeldHTTPResponse: @unchecked Sendable {
|
||||
struct Result {
|
||||
let status: Int?
|
||||
let data: Data?
|
||||
let errorDescription: String?
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private var result: Result?
|
||||
|
||||
func complete(status: Int?, data: Data?, errorDescription: String?) {
|
||||
lock.lock()
|
||||
result = Result(status: status, data: data, errorDescription: errorDescription)
|
||||
lock.unlock()
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
/// Bounded wait for the response; nil = still pending after `timeout`.
|
||||
func waitForResult(timeout: TimeInterval) -> Result? {
|
||||
guard semaphore.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yaojia.webterm
|
||||
# Target-level on purpose (T-iOS-19 finding): XcodeGen's target platform
|
||||
# default ("1,2") overrides a project-level value, shipping an
|
||||
# installable-but-untested iPad layout. Target-level wins.
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
info:
|
||||
path: App/WebTerm/Resources/Info.plist
|
||||
properties:
|
||||
@@ -104,9 +108,26 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/WebTerm.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WebTerm"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
|
||||
# XCUITest — exactly ONE scripted happy path per plan §9 (T-iOS-18 finding):
|
||||
# pair via manual entry against a live loopback server → attach → type →
|
||||
# approve a held gate. Kept deliberately minimal (fragile-surface rule).
|
||||
WebTermUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
sources:
|
||||
- App/WebTermUITests
|
||||
dependencies:
|
||||
- target: WebTerm
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TEST_TARGET_NAME: WebTerm
|
||||
|
||||
schemes:
|
||||
WebTerm:
|
||||
build:
|
||||
@@ -119,3 +140,14 @@ schemes:
|
||||
config: Debug
|
||||
targets:
|
||||
- WebTermTests
|
||||
# Separate scheme so the slow UI pass never gates the fast unit loop; CI
|
||||
# runs it as its own leg (see .github/workflows/ios.yml).
|
||||
WebTermUITests:
|
||||
build:
|
||||
targets:
|
||||
WebTerm: all
|
||||
WebTermUITests: [test]
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- WebTermUITests
|
||||
|
||||
Reference in New Issue
Block a user