Skip to content

Commit 142d156

Browse files
authored
ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7) (#1789)
* ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7) * fix(ci): skip the runner server entry point in the nightly and validate both test flags * docs(ci): restate the nightly lane cost and timeout honestly * docs(ci): stop quoting XCTest counts that drift between commits * ci(ios): tighten the nightly timeout to the measured suite duration
1 parent 0f4f322 commit 142d156

12 files changed

Lines changed: 1021 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ jobs:
177177
uses: ./.github/actions/run-gate
178178
with: { gate: gate-manifest }
179179

180+
# Same family as the manifest above — a CI selection that has stopped selecting what
181+
# it claims. ios.yml runs a hand-written subset of the runner XCTest methods through an
182+
# `-only-testing:` list, and xcodebuild treats an identifier that matches nothing as
183+
# an empty selection rather than an error, so a rename drops a test with no signal —
184+
# in both directions, since a typo in xctest-nightly.yml's `-skip-testing:` entry
185+
# re-arms a 24-hour hang. Parse-only, no Xcode (#1781 A7).
186+
- name: Check the PR XCTest selection still names real tests
187+
uses: ./.github/actions/run-gate
188+
with: { gate: xctest-selection }
189+
180190
maestro-conformance:
181191
name: Maestro Conformance Oracle
182192
runs-on: ubuntu-latest
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
name: XCTest Nightly
2+
3+
# The full iOS runner XCTest suite (#1781 A7). The PR lane (ios.yml) names a hand-written
4+
# subset of the target's methods in an `-only-testing:` list; everything outside that list ran
5+
# nowhere at all. This lane drops the filter and runs the test plan whole, skipping only
6+
# `testCommand` — which is the runner's server entry point rather than a test (see the step
7+
# below).
8+
#
9+
# No count is quoted here on purpose. `pnpm check:xctest-selection` prints the live split
10+
# (declared / PR-selected / skipped / nightly-only) and is the only place those numbers are
11+
# derived rather than remembered. A hand-copied figure in a comment is the same class of
12+
# defect this lane exists to close: between this file being written and first review, the
13+
# suite gained three methods and the PR list gained five entries, so every number originally
14+
# written here was already wrong.
15+
#
16+
# Under the lane rule in #1781:
17+
#
18+
# - Catches: regressions in the runner-semantics tests the PR list leaves out — selector
19+
# matching, snapshot traversal, command journal, sequence execution, tv remote, keyboard,
20+
# scroll/gesture policy. Nothing else executes them. Note the reachable set is smaller
21+
# than the declared set: `testCommand` is skipped, and the `…OnTvOS` tests sit behind
22+
# `#if os(tvOS)` and cannot execute on this lane's iOS Simulator destination.
23+
# - Evidence: the list has never been re-derived since it was written by hand; the tests it
24+
# omits have been dark for their whole life, so their current state is unmeasured. The
25+
# first red night is the evidence this lane exists to produce.
26+
# - Cost: this is a public repository, so nothing here is billed. The real cost is one macOS
27+
# concurrency slot per night. The Apple runner build shares ios.yml's cache (identical
28+
# inputs and build variant), but scheduled runs only see DEFAULT-BRANCH caches, so the hit
29+
# depends on ios.yml's `push: main` run having populated the cache at the same source
30+
# hash — on a miss this lane also pays a full runner build. Adds nothing to any PR.
31+
# - Kill criterion: when A7's classification lands — pure Swift decision tests moved to a
32+
# fast unit target, stale tests deleted, the rest kept on the simulator — this lane goes
33+
# if the surviving set is small enough to run on every PR.
34+
#
35+
# Scheduled + manual only. It is deliberately NOT a PR gate: ios.yml is already over half of
36+
# this repo's CI spend (#1781 A9), and moving the unrun set onto it would make that worse for
37+
# tests whose flakiness on a shared runner is unknown.
38+
#
39+
# Note on `concurrency` below: it is keyed on `github.ref`, so a manual dispatch on the same
40+
# ref cancels an in-flight scheduled run. That is the intended trade — an operator asking for
41+
# a run wants the fresh one — but it means "dispatch while the nightly is running" loses the
42+
# nightly's result rather than queueing behind it.
43+
44+
on:
45+
schedule:
46+
- cron: '30 4 * * *'
47+
workflow_dispatch:
48+
49+
permissions:
50+
contents: read
51+
actions: read
52+
53+
concurrency:
54+
group: ci-${{ github.workflow }}-${{ github.ref }}
55+
cancel-in-progress: true
56+
57+
jobs:
58+
full-suite:
59+
name: iOS Runner Full XCTest Suite
60+
runs-on: macos-26
61+
# Measured, not guessed. A full local run of this exact command on matching toolchain
62+
# (Xcode 26.2, iOS 26.2 Simulator) executed 154 tests in 120s wall — the suite itself is
63+
# ~2 minutes, not the bulk of the job. What dominates the budget is the runner build on a
64+
# derived-data cache MISS, plus checkout and simulator boot. ios.yml carries 80 minutes
65+
# while doing strictly more than this lane (same build and boot, plus replays, prepare,
66+
# and the fixture E2E), so half of that is generous headroom for a cache-miss night and
67+
# still caps a wedged run at 45 minutes instead of two hours of held macOS slot.
68+
timeout-minutes: 45
69+
env:
70+
# Must match ios.yml. The runtime version rides in the derived-data cache key, and
71+
# AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS is BOTH an input to the cache's build-variant
72+
# hash and the `-D AGENT_DEVICE_RUNNER_UNIT_TESTS` compile flag that makes the RunnerTests
73+
# methods exist at all. Dropping it would build a different variant (cache miss) whose
74+
# bundle contains no tests, and `xcodebuild` reports running zero tests as success — the
75+
# "silent 0-test green" this lane would then be. The test-count assertion below is the
76+
# backstop for exactly that.
77+
IOS_RUNTIME_VERSION: '26.2'
78+
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
79+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
80+
RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/RunnerTests.xcresult
81+
RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/test-results-summary.json
82+
steps:
83+
- name: Checkout
84+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
85+
86+
- name: Setup toolchain
87+
uses: ./.github/actions/setup-node-pnpm
88+
with:
89+
cache-dependency-path: |
90+
pnpm-lock.yaml
91+
examples/test-app/pnpm-lock.yaml
92+
93+
# Byte-identical inputs to ios.yml's step, so both lanes restore the same cache entry
94+
# and only the first one to run on a commit pays for the build.
95+
- name: Restore and build iOS XCTest runner
96+
uses: ./.github/actions/setup-apple-runner-build
97+
with:
98+
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
99+
cache-key-prefix: ios-runner-prebuilt
100+
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
101+
gate: swift-runner-ios
102+
xcuitest-platform: ios
103+
xcuitest-destination: generic/platform=iOS Simulator
104+
105+
- name: Boot iOS test simulator
106+
id: ios-simulator
107+
uses: ./.github/actions/boot-ios-test-simulator
108+
with:
109+
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
110+
preferred-device-name: iPhone 17 Pro
111+
112+
# Same command as ios.yml's targeted step minus every `-only-testing:` flag, so the
113+
# xctestrun's own test plan decides what runs — with one exception.
114+
#
115+
# `RunnerTests/testCommand` is not a test. It is the runner's server entry point: it
116+
# opens an NWListener and blocks in `XCTWaiter.wait(timeout: 24 * 60 * 60)` until a
117+
# client closes the session, which is exactly how production drives it
118+
# (src/platforms/apple/core/runner/runner-session.ts always passes it as the sole
119+
# `-only-testing:`). It compiles unconditionally — the `#if AGENT_DEVICE_RUNNER_UNIT_TESTS`
120+
# block ends well above it — so an unfiltered run reaches it in alphabetical order and
121+
# hangs the job until `timeout-minutes`. Its one escape hatch,
122+
# AGENT_DEVICE_RUNNER_NOOP_STARTUP, is an environment variable, and the comment above
123+
# the flag records that env plumbing into a simulator test process is not dependable;
124+
# `-skip-testing:` is the lever that works from here.
125+
#
126+
# A typo in that identifier silently re-arms the hang, so
127+
# `pnpm check:xctest-selection` validates `-skip-testing:` exactly like `-only-testing:`.
128+
- name: Run the full iOS runner XCTest suite
129+
run: |
130+
set -euo pipefail
131+
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
132+
test -n "$XCTESTRUN_PATH"
133+
mkdir -p "$(dirname "$RESULT_BUNDLE_PATH")"
134+
xcodebuild test-without-building \
135+
-xctestrun "$XCTESTRUN_PATH" \
136+
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
137+
-skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \
138+
-resultBundlePath "$RESULT_BUNDLE_PATH"
139+
140+
# Best-effort and never the job's verdict on its own; the step below is what asserts.
141+
# `--compact` first because a red night's summary is the large one, and the job summary
142+
# has a 1 MiB cap — the plain form is the fallback if an Xcode version rejects the flag.
143+
- name: Extract the test-results summary
144+
if: always()
145+
run: |
146+
set -uo pipefail
147+
[ -d "$RESULT_BUNDLE_PATH" ] || exit 0
148+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" --compact \
149+
> "$RESULT_SUMMARY_PATH" 2>/dev/null ||
150+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" \
151+
> "$RESULT_SUMMARY_PATH" 2>/dev/null ||
152+
rm -f "$RESULT_SUMMARY_PATH"
153+
exit 0
154+
155+
# Reports the run AND is the lane's own liveness check. `xcodebuild` exits 0 when a
156+
# selection matches nothing, so "green" and "ran no tests" are the same log tail: a
157+
# build variant without the unit-test compile flag, an empty test plan, or a renamed
158+
# target would all read as a healthy night. Asserting the run executed tests is what
159+
# tells those apart. The failure list is capped so a badly red night cannot blow the
160+
# 1 MiB job-summary limit and lose the headline with it.
161+
- name: Report the run and assert it executed tests
162+
if: always()
163+
run: |
164+
set -euo pipefail
165+
if [ ! -s "$RESULT_SUMMARY_PATH" ]; then
166+
echo 'No usable test-results summary was produced, so nothing here can show a test ran.'
167+
echo 'The suite step above failed before or during the run; read its log first.'
168+
echo '### iOS runner full XCTest suite' >> "$GITHUB_STEP_SUMMARY"
169+
echo 'No result-bundle summary; see the run log and the uploaded bundle.' >> "$GITHUB_STEP_SUMMARY"
170+
exit 1
171+
fi
172+
node --experimental-strip-types scripts/xctest-run-summary.ts
173+
174+
- name: Upload result bundle
175+
if: always()
176+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
177+
with:
178+
name: xctest-nightly-results-${{ github.run_id }}-${{ github.run_attempt }}
179+
path: .tmp/xctest-nightly
180+
if-no-files-found: warn

docs/agents/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The mapping it encodes, for when you need to run a gate directly or reason about
3737
| Platform/device response — anything emitting `platform`/`appleOs` on the wire, or shaping a daemon response | `pnpm test:integration:provider` **and** `pnpm test:coverage` |
3838
| Cross-platform behavior | `pnpm test:integration` |
3939
| Apple runner / Swift | Build the changed target with `pnpm build:xcuitest:<platform>`; use `pnpm build:xcuitest` only for shared iOS/macOS changes |
40+
| Runner XCTest methods (`apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/**`) | `pnpm check:xctest-selection`, which prints the live declared/PR-selected/skipped/nightly-only split — the counts move often enough that quoting one here would rot. `ios.yml` runs a hand-written subset through an `-only-testing:` list and `xctest-nightly.yml` excludes one method via `-skip-testing:`; `xcodebuild` treats an identifier matching nothing as an empty selection rather than an error, in both directions — a rename drops a test from the PR lane silently, or re-admits `RunnerTests/testCommand`, the runner's 24-hour server entry point, into the nightly and hangs it. The check fails only on a listed name no source declares; a test in no list is expected, because the nightly runs the suite whole |
4041
| CLI help/guidance (`src/cli/parser/cli-help.ts`, `src/cli-schema/`) | `pnpm exec vitest run src/cli/parser/__tests__ src/cli-schema/command-schema-guards.test.ts scripts/__tests__` — the `scripts/__tests__` gates enforce help-topic benchmark coverage and pin the bench's quoted CLI samples to the real renderers |
4142
| Help benchmark cases (`scripts/help-conformance-*.mjs`) | `pnpm exec vitest run scripts/__tests__` (deterministic gates); model-backed: `pnpm bench:help-conformance` (paid LLM calls, local only) |
4243
| `.ad` grammar (`src/replay/script.ts`, gesture arity, replay vars) | `pnpm exec vitest run --project unit-core test/replay-compat` — the frozen replay-compat corpus asserts which released script surfaces still parse; a flipped verdict is edited in `test/replay-compat/manifest.ts`, never in the script. Adding or re-pinning a corpus entry also runs `pnpm check:replay-compat`, which re-derives each entry from its release tag in git history |

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,15 @@
147147
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
148148
"check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts",
149149
"check:daemon-wire-compat:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/wire-compat/model.test.ts",
150+
"check:xctest-selection": "node --experimental-strip-types scripts/check-xctest-selection.ts",
150151
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
151152
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts scripts/swift-toolchain-tmpdir.test.ts",
152153
"check:freerange": "fr",
153154
"check:quick": "pnpm lint && pnpm typecheck",
154155
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
155156
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
156157
"version": "pnpm sync:mcp-metadata && git add server.json",
157-
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
158+
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
158159
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
159160
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
160161
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// The nightly XCTest lane's reporter is also its liveness check, and both halves only ever
2+
// execute on a macOS runner at 04:30 — so they are proven here instead.
3+
4+
import { describe, expect, test } from 'vitest';
5+
import {
6+
livenessFailure,
7+
MAX_FAILURE_TEXT,
8+
MAX_LISTED_FAILURES,
9+
renderSummary,
10+
type ResultSummary,
11+
} from '../xctest-run-summary.ts';
12+
13+
function failures(count: number, text = 'assertion failed') {
14+
return Array.from({ length: count }, (_, index) => ({
15+
testName: `testCase${index}()`,
16+
failureText: text,
17+
}));
18+
}
19+
20+
describe('the liveness check', () => {
21+
test('a run that executed no tests fails, because xcodebuild calls that success', () => {
22+
// The lane's whole reason to assert: a build without the unit-test compile flag, an
23+
// empty test plan, or a `-skip-testing:` entry that swallowed the suite all exit 0.
24+
const failure = livenessFailure({ result: 'Passed', totalTestCount: 0 });
25+
expect(failure).toContain('executed no tests');
26+
expect(failure).toContain('AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS');
27+
expect(failure).toContain('-skip-testing');
28+
});
29+
30+
test('a summary missing the count entirely is treated as no tests, not as unknown', () => {
31+
expect(livenessFailure({})).not.toBeNull();
32+
});
33+
34+
test('a run that executed tests passes, red or green', () => {
35+
expect(livenessFailure({ totalTestCount: 153, result: 'Failed', failedTests: 9 })).toBeNull();
36+
expect(livenessFailure({ totalTestCount: 1, result: 'Passed' })).toBeNull();
37+
});
38+
});
39+
40+
describe('the job summary', () => {
41+
const green: ResultSummary = {
42+
result: 'Passed',
43+
totalTestCount: 153,
44+
passedTests: 153,
45+
failedTests: 0,
46+
skippedTests: 0,
47+
expectedFailures: 0,
48+
startTime: 1000,
49+
finishTime: 1450,
50+
};
51+
52+
test('leads with the headline a reader needs', () => {
53+
const rendered = renderSummary(green);
54+
expect(rendered).toContain('- result: **Passed**');
55+
expect(rendered).toContain('- executed: **153** (passed 153, failed 0, skipped 0');
56+
expect(rendered).toContain('- duration: 450s');
57+
expect(rendered).not.toContain('#### Failures');
58+
});
59+
60+
test('reports an unknown duration rather than NaN when the times are absent', () => {
61+
expect(renderSummary({ totalTestCount: 1 })).toContain('- duration: unknown');
62+
});
63+
64+
test('lists failures and names each one', () => {
65+
const rendered = renderSummary({ ...green, result: 'Failed', testFailures: failures(3) });
66+
expect(rendered).toContain('#### Failures');
67+
expect(rendered).toContain('`testCase0()`');
68+
expect(rendered).toContain('assertion failed');
69+
});
70+
71+
test('caps the list, and says how many it dropped', () => {
72+
// The night this lane matters most is the night the failure list is longest, and the
73+
// job summary is capped at 1 MiB — losing the headline to the tail would be the worst
74+
// possible trade.
75+
const rendered = renderSummary({
76+
...green,
77+
result: 'Failed',
78+
testFailures: failures(MAX_LISTED_FAILURES + 7),
79+
});
80+
const listed = rendered.split('\n').filter((line) => line.startsWith('- `test')).length;
81+
expect(listed).toBe(MAX_LISTED_FAILURES);
82+
expect(rendered).toContain('…and 7 more');
83+
});
84+
85+
test('truncates one enormous failure message instead of letting it dominate', () => {
86+
const rendered = renderSummary({
87+
...green,
88+
result: 'Failed',
89+
testFailures: failures(1, 'x'.repeat(5000)),
90+
});
91+
expect(rendered).toContain('x'.repeat(MAX_FAILURE_TEXT));
92+
expect(rendered).not.toContain('x'.repeat(MAX_FAILURE_TEXT + 1));
93+
});
94+
95+
test('flattens newlines so a multi-line stack cannot forge markdown structure', () => {
96+
const rendered = renderSummary({
97+
...green,
98+
result: 'Failed',
99+
testFailures: [{ testName: 'testX()', failureText: 'line one\n#### Injected\nline two' }],
100+
});
101+
// Markdown headings only bind at the start of a line, so flattening is what disarms
102+
// the injection: the text survives verbatim, but it can no longer open a section.
103+
expect(rendered.split('\n').filter((line) => line.startsWith('#'))).toEqual([
104+
'### iOS runner full XCTest suite',
105+
'#### Failures',
106+
]);
107+
expect(rendered).toContain('line one #### Injected line two');
108+
});
109+
110+
test('falls back to the identifier when a failure carries no test name', () => {
111+
const rendered = renderSummary({
112+
...green,
113+
testFailures: [{ testIdentifierString: 'RunnerTests/testY()', failureText: 'boom' }],
114+
});
115+
expect(rendered).toContain('`RunnerTests/testY()`');
116+
});
117+
});

0 commit comments

Comments
 (0)