# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9: # # 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate # (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export # TOTALS, which count statically-linked DEPENDENCY sources compiled into # the test binary (a known flaw, fix assigned to T-iOS-16). The corrected # per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh # (jq keeps only Packages/

/Sources/, excluding *Placeholder*). # 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on # an iPhone AND an iPad simulator: ViewModels/components of the app glue # layer, on both the compact and the regular size class. # 3. integration-tests — Swift Testing against the REAL Node server. The # ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral # loopback port (127.0.0.1: is always in the derived Origin # whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer # is the standing anti-drift gate between the Swift-replicated protocol # and the server implementation — hence the src/** path triggers below. name: ios on: push: paths: - "ios/**" - ".github/workflows/ios.yml" # integration layer guards client<->server protocol drift: - "src/**" - "package.json" - "package-lock.json" pull_request: paths: - "ios/**" - ".github/workflows/ios.yml" - "src/**" - "package.json" - "package-lock.json" jobs: # Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate. # The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the # mTLS identity/keychain package, previously the only ungated one at 55.76%). # TestSupport runs tests below without a gate (test doubles are excluded). package-tests: runs-on: macos-15 strategy: fail-fast: false matrix: package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS] steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer - name: swift test + own-sources coverage gate (>= 80%) run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }} testsupport-tests: runs-on: macos-15 steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer - name: swift test (TestSupport — no coverage gate; it IS the test doubles) run: swift test --package-path ios/Packages/TestSupport # Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the # compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive # NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only # compact). One matrix instead of two near-identical jobs. # # THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains # LiveServerSmokeTests, whose SimServerHarness spawns # `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no # node_modules, so the harness throws # setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci # and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every # run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the # live-server smoke; the iPad leg SKIPS that suite instead of paying a second # node-pty native build. Rationale: the iPad leg exists to exercise the app's # regular-width layout, not to re-verify the client↔server contract, which is # already covered three times over (this leg, integration-tests, ui-test). # `-skip-testing` (rather than a test plan) keeps the change inside this file: # test plans live in ios/project.yml, owned by another task. app-tests: runs-on: macos-15 timeout-minutes: 45 strategy: fail-fast: false matrix: include: - leg: iphone device: iPhone 16 needsNode: true extraArgs: "" - leg: ipad device: iPad Pro 11-inch (M4) needsNode: false extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests" 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 if: matrix.needsNode with: node-version: 22 - name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx) if: matrix.needsNode run: npm ci - name: Install XcodeGen run: brew install xcodegen - name: Generate project run: cd ios && xcodegen generate - name: xcodebuild test (WebTermTests, ${{ matrix.device }} 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=${{ matrix.device }}' \ ${{ matrix.extraArgs }} \ 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 # runner); the Swift ServerHarness then boots the server itself. integration-tests: runs-on: macos-15 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: 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). # # iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch # (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran — # the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT # suite: HappyPathUITests has no regular-width branch at all (no sidebar # reveal), so running it on iPad would fail for a missing-feature reason rather # than a regression — making the happy path iPad-adaptive is app-layer work, # tracked separately. ui-test: runs-on: macos-15 timeout-minutes: 45 strategy: fail-fast: false matrix: include: - leg: iphone device: iPhone 16 suites: "" - leg: ipad device: iPad Pro 11-inch (M4) suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests" 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, ${{ matrix.device }} simulator) # TEST_RUNNER_ must be an ENV VAR of the xcodebuild process (it # strips the prefix and injects 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=${{ matrix.device }}' \ ${{ matrix.suites }} 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: local machines are NOT expected to hold the ~7 GB iOS 17 # runtime, so this leg is never reproducible on a dev box. # # NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the # runner image had no iOS 17.x runtime, and the test step was gated on # `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole # deployment-floor round vanished and the job still reported GREEN. A leg that # can silently stop testing is worse than no leg. Now: if the runtime is # missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS # -buildVersion`); if the download does not produce a usable runtime, the job # FAILS. The test step is unconditional. ios17-floor-tests: runs-on: macos-15 timeout-minutes: 90 # the runtime download alone can take ~15 min 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: Ensure an iOS 17.x simulator runtime (download if the image lacks it) id: sim17 env: IOS17_FALLBACK_VERSION: "17.5" run: | find_runtime() { xcrun simctl list runtimes \ | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \ | tail -n 1 } runtime="$(find_runtime || true)" if [ -z "$runtime" ]; then echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)" sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true runtime="$(find_runtime || true)" fi if [ -z "$runtime" ]; then echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2 xcrun simctl list runtimes >&2 exit 1 fi udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")" echo "created $udid with $runtime" echo "udid=$udid" >> "$GITHUB_OUTPUT" # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed # (ad-hoc, certificate-free on simulator) app host — unsigned = -34018. # # -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that # suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`. # This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on # iOS 17), not the server contract, so it stays Node-free. - name: xcodebuild test (WebTermTests on the iOS 17.x floor) run: | xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ -destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \ -skip-testing:WebTermTests/LiveServerSmokeTests \ test