Skip to content

Commit 1a9e5da

Browse files
committed
ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7)
1 parent 75a4817 commit 1a9e5da

9 files changed

Lines changed: 541 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ jobs:
171171
uses: ./.github/actions/run-gate
172172
with: { gate: gate-manifest }
173173

174+
# Same family as the manifest above — a CI selection that has stopped selecting what
175+
# it claims. ios.yml runs 37 of the 153 runner XCTest methods through a hand-written
176+
# `-only-testing:` list, and xcodebuild treats an identifier that matches nothing as
177+
# an empty selection rather than an error, so a rename drops a test with no signal.
178+
# Parse-only, no Xcode; the other 116 methods run in xctest-nightly.yml (#1781 A7).
179+
- name: Check the PR XCTest selection still names real tests
180+
uses: ./.github/actions/run-gate
181+
with: { gate: xctest-selection }
182+
174183
maestro-conformance:
175184
name: Maestro Conformance Oracle
176185
runs-on: ubuntu-latest
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
name: XCTest Nightly
2+
3+
# The full iOS runner XCTest suite (#1781 A7). The PR lane (ios.yml) runs 37 of the 153
4+
# `RunnerTests` methods through a hand-written `-only-testing:` list; before this lane the
5+
# other 116 ran nowhere at all. This one drops the filter and runs the test plan whole.
6+
#
7+
# Under the lane rule in #1781:
8+
#
9+
# - Catches: regressions in the ~116 runner-semantics tests the PR list leaves out —
10+
# selector matching, snapshot traversal, command journal, sequence execution, tv remote,
11+
# keyboard, scroll/gesture policy. Nothing else executes them.
12+
# - Evidence: the list has never been re-derived since it was written by hand; the tests it
13+
# omits have been dark for their whole life, so their current state is unmeasured. The
14+
# first red night is the evidence this lane exists to produce.
15+
# - Cost: one macOS job per night. The Apple runner build is shared with ios.yml's cache
16+
# (identical inputs and build variant), so a night that follows a green ios.yml run pays
17+
# only the boot + full-suite time on top of a cache restore. Adds nothing to any PR.
18+
# - Kill criterion: when A7's classification lands — pure Swift decision tests moved to a
19+
# fast unit target, stale tests deleted, the rest kept on the simulator — this lane goes
20+
# if the surviving set is small enough to run on every PR.
21+
#
22+
# Scheduled + manual only. It is deliberately NOT a PR gate: ios.yml is already over half of
23+
# this repo's CI spend (#1781 A9), and adding 116 tests to it would make that worse for a set
24+
# of tests whose flakiness on a shared runner is unknown.
25+
26+
on:
27+
schedule:
28+
- cron: '30 4 * * *'
29+
workflow_dispatch:
30+
31+
permissions:
32+
contents: read
33+
actions: read
34+
35+
concurrency:
36+
group: ci-${{ github.workflow }}-${{ github.ref }}
37+
cancel-in-progress: true
38+
39+
jobs:
40+
full-suite:
41+
name: iOS Runner Full XCTest Suite
42+
runs-on: macos-26
43+
# Generous on purpose: the PR lane's 37 tests are a few minutes inside an 80-minute job
44+
# that also runs replays and E2E, and this lane's total is unmeasured until it has run.
45+
timeout-minutes: 120
46+
env:
47+
# Must match ios.yml. The runtime version rides in the derived-data cache key, and
48+
# AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS is BOTH an input to the cache's build-variant
49+
# hash and the `-D AGENT_DEVICE_RUNNER_UNIT_TESTS` compile flag that makes the RunnerTests
50+
# methods exist at all. Dropping it would build a different variant (cache miss) whose
51+
# bundle contains no tests, and `xcodebuild` reports running zero tests as success — the
52+
# "silent 0-test green" this lane would then be. The test-count assertion below is the
53+
# backstop for exactly that.
54+
IOS_RUNTIME_VERSION: '26.2'
55+
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
56+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
57+
RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/RunnerTests.xcresult
58+
RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/test-results-summary.json
59+
steps:
60+
- name: Checkout
61+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
62+
63+
- name: Setup toolchain
64+
uses: ./.github/actions/setup-node-pnpm
65+
with:
66+
cache-dependency-path: |
67+
pnpm-lock.yaml
68+
examples/test-app/pnpm-lock.yaml
69+
70+
# Byte-identical inputs to ios.yml's step, so both lanes restore the same cache entry
71+
# and only the first one to run on a commit pays for the build.
72+
- name: Restore and build iOS XCTest runner
73+
uses: ./.github/actions/setup-apple-runner-build
74+
with:
75+
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
76+
cache-key-prefix: ios-runner-prebuilt
77+
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
78+
gate: swift-runner-ios
79+
xcuitest-platform: ios
80+
xcuitest-destination: generic/platform=iOS Simulator
81+
82+
- name: Boot iOS test simulator
83+
id: ios-simulator
84+
uses: ./.github/actions/boot-ios-test-simulator
85+
with:
86+
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
87+
preferred-device-name: iPhone 17 Pro
88+
89+
# Same command as ios.yml's targeted step minus every `-only-testing:` flag, so the
90+
# xctestrun's own test plan decides what runs.
91+
- name: Run the full iOS runner XCTest suite
92+
run: |
93+
set -euo pipefail
94+
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
95+
test -n "$XCTESTRUN_PATH"
96+
mkdir -p "$(dirname "$RESULT_BUNDLE_PATH")"
97+
xcodebuild test-without-building \
98+
-xctestrun "$XCTESTRUN_PATH" \
99+
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
100+
-resultBundlePath "$RESULT_BUNDLE_PATH"
101+
102+
# Runs even when the suite failed: a failing run is exactly when the per-test breakdown
103+
# is worth having, and this step never changes the job's verdict on its own.
104+
- name: Summarize the run
105+
if: always()
106+
run: |
107+
set -uo pipefail
108+
{
109+
echo '### iOS runner full XCTest suite'
110+
if [ -d "$RESULT_BUNDLE_PATH" ] &&
111+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" \
112+
> "$RESULT_SUMMARY_PATH" 2>/dev/null; then
113+
echo '```json'
114+
cat "$RESULT_SUMMARY_PATH"
115+
echo '```'
116+
else
117+
echo 'No result bundle summary was produced; see the run log and the uploaded bundle.'
118+
fi
119+
} >> "$GITHUB_STEP_SUMMARY"
120+
121+
# The lane's own liveness check. `xcodebuild` exits 0 when a selection matches nothing,
122+
# so "green" and "ran no tests" are the same log tail; a build variant without the
123+
# unit-test compile flag, an empty test plan, or a renamed target would all read as a
124+
# healthy night. Asserting the run executed tests is what tells those apart.
125+
- name: Assert the suite actually ran tests
126+
if: always()
127+
run: |
128+
set -euo pipefail
129+
if [ ! -f "$RESULT_SUMMARY_PATH" ]; then
130+
echo 'No test-results summary was produced, so nothing here can show a test ran.'
131+
echo 'The suite step above failed before or during the run; read its log first.'
132+
exit 1
133+
fi
134+
TOTAL="$(RESULT_SUMMARY_PATH="$RESULT_SUMMARY_PATH" node -e '
135+
const summary = JSON.parse(require("node:fs").readFileSync(process.env.RESULT_SUMMARY_PATH, "utf8"));
136+
process.stdout.write(String(summary.totalTestCount ?? 0));
137+
')"
138+
echo "Executed $TOTAL test(s)."
139+
if [ "$TOTAL" -lt 1 ]; then
140+
echo 'The full-suite lane executed no tests, which xcodebuild reports as success.'
141+
echo 'Check AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS, the xctestrun test plan, and the target name.'
142+
exit 1
143+
fi
144+
145+
- name: Upload result bundle
146+
if: always()
147+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
148+
with:
149+
name: xctest-nightly-results-${{ github.run_id }}-${{ github.run_attempt }}
150+
path: .tmp/xctest-nightly
151+
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`. `ios.yml` runs 37 of the 153 methods through a hand-written `-only-testing:` list, and `xcodebuild` treats an identifier matching nothing as an empty selection rather than an error — so a rename drops a test silently. The check fails only on a listed name no source declares; a test that is in no list is expected, because `.github/workflows/xctest-nightly.yml` runs the suite whole every night |
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
@@ -143,14 +143,15 @@
143143
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
144144
"check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts",
145145
"check:daemon-wire-compat:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/wire-compat/model.test.ts",
146+
"check:xctest-selection": "node --experimental-strip-types scripts/check-xctest-selection.ts",
146147
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
147148
"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",
148149
"check:freerange": "fr",
149150
"check:quick": "pnpm lint && pnpm typecheck",
150151
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
151152
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
152153
"version": "pnpm sync:mcp-metadata && git add server.json",
153-
"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",
154+
"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",
154155
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
155156
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
156157
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// The check that keeps ios.yml's hand-written `-only-testing:` list honest is itself only
2+
// as good as its two parsers, and both of its inputs are files nobody edits with this check
3+
// in mind. So: the real tree must pass, and a planted typo in the real workflow text must
4+
// fail. Synthetic sources cover the shapes the real tree happens not to contain today.
5+
6+
import fs from 'node:fs';
7+
import path from 'node:path';
8+
import { describe, expect, test } from 'vitest';
9+
import {
10+
buildReport,
11+
formatSummary,
12+
loadReport,
13+
parseDeclaredTests,
14+
parseSelectedTests,
15+
PR_WORKFLOW_FILE,
16+
reportFailures,
17+
RUNNER_TESTS_DIR,
18+
readSwiftSources,
19+
} from '../check-xctest-selection.ts';
20+
21+
const repoRoot = path.resolve(import.meta.dirname, '..', '..');
22+
const TARGET = 'AgentDeviceRunnerUITests';
23+
24+
function source(text: string) {
25+
return [{ file: 'RunnerTests+Fixture.swift', text }];
26+
}
27+
28+
describe('the real tree', () => {
29+
test('every `-only-testing:` entry in ios.yml names a declared test', () => {
30+
expect(reportFailures(loadReport(repoRoot))).toEqual([]);
31+
});
32+
33+
test('the PR lane selects a real subset — some tests run only in the nightly', () => {
34+
const report = loadReport(repoRoot);
35+
// Not pinned to today's 37/153: the point is that the list is a proper subset, so
36+
// neither "the filter is gone" nor "the scan found nothing" reads as healthy.
37+
expect(report.selected.length).toBeGreaterThan(0);
38+
expect(report.declared.length).toBeGreaterThan(report.selected.length);
39+
for (const entry of report.selected) {
40+
expect(entry.identifier.startsWith(`${TARGET}/`)).toBe(true);
41+
}
42+
// The ratio is the whole point of the output — a passing run has to report it, or
43+
// nobody reading CI logs can see the PR lane shrinking.
44+
expect(formatSummary(report)).toContain(
45+
`${report.selected.length} of ${report.declared.length}`,
46+
);
47+
expect(formatSummary(report)).toContain(
48+
`the other ${report.declared.length - report.selected.length} run in`,
49+
);
50+
});
51+
52+
test('every declared test is addressable as the identifier a filter would use', () => {
53+
// The count is the load-bearing claim: it must equal the `func test` methods in the
54+
// sources, derived here the crude way the issue counted them.
55+
const directory = path.join(repoRoot, RUNNER_TESTS_DIR);
56+
const grepped = readSwiftSources(directory).reduce(
57+
(total, entry) => total + (entry.text.match(/^ {2}(?:[\w@]+ )*func test/gm)?.length ?? 0),
58+
0,
59+
);
60+
expect(loadReport(repoRoot).declared).toHaveLength(grepped);
61+
});
62+
});
63+
64+
describe('a planted typo', () => {
65+
test('a renamed test in the workflow list is reported with its line', () => {
66+
const workflow = fs.readFileSync(path.join(repoRoot, PR_WORKFLOW_FILE), 'utf8');
67+
const first = parseSelectedTests(workflow)[0];
68+
if (!first) throw new Error('ios.yml has no -only-testing entries to plant a typo in');
69+
const typo = `${first.identifier}Renamed`;
70+
const report = buildReport(
71+
TARGET,
72+
readSwiftSources(path.join(repoRoot, RUNNER_TESTS_DIR)),
73+
workflow.replace(first.identifier, typo),
74+
);
75+
76+
expect(report.unknown).toEqual([{ identifier: typo, line: first.line }]);
77+
expect(reportFailures(report).join('\n')).toContain(typo);
78+
});
79+
80+
test('a deleted test is reported even though the surviving list still passes', () => {
81+
const swift = source(
82+
'extension RunnerTests {\n func testKept() {}\n func testGone() {}\n}\n',
83+
);
84+
const workflow = [
85+
` -only-testing:${TARGET}/RunnerTests/testKept \\`,
86+
` -only-testing:${TARGET}/RunnerTests/testGone`,
87+
].join('\n');
88+
89+
const before = buildReport(TARGET, swift, workflow);
90+
expect(before.unknown).toEqual([]);
91+
92+
const after = buildReport(
93+
TARGET,
94+
source('extension RunnerTests {\n func testKept() {}\n}\n'),
95+
workflow,
96+
);
97+
expect(after.unknown.map((entry) => entry.identifier)).toEqual([
98+
`${TARGET}/RunnerTests/testGone`,
99+
]);
100+
});
101+
});
102+
103+
describe('the declaration scan', () => {
104+
test('binds a method to the top-level type that encloses it', () => {
105+
expect(
106+
parseDeclaredTests(
107+
TARGET,
108+
source(
109+
'final class RunnerTests: XCTestCase {\n func testInClass() {}\n}\n\n' +
110+
'extension RunnerTests {\n func testInExtension() throws {}\n}\n\n' +
111+
'final class OtherTests: XCTestCase {\n func testElsewhere() async throws {}\n}\n',
112+
),
113+
),
114+
).toEqual([
115+
`${TARGET}/OtherTests/testElsewhere`,
116+
`${TARGET}/RunnerTests/testInClass`,
117+
`${TARGET}/RunnerTests/testInExtension`,
118+
]);
119+
});
120+
121+
test('ignores declarations no filter could address', () => {
122+
expect(
123+
parseDeclaredTests(
124+
TARGET,
125+
source(
126+
'extension RunnerTests {\n' +
127+
// A helper type declared inside a test body must not capture the methods after
128+
// it, and `class func` must not read as a type declaration named `func`.
129+
' class func makeHelper() {}\n' +
130+
' func testWithNestedHelper() {\n' +
131+
' final class ResultBox {}\n' +
132+
' func testLocal() {}\n' +
133+
' }\n' +
134+
' // func testCommentedOut() {}\n' +
135+
' func testAfterNesting() {}\n' +
136+
'}\n',
137+
),
138+
),
139+
).toEqual([
140+
`${TARGET}/RunnerTests/testAfterNesting`,
141+
`${TARGET}/RunnerTests/testWithNestedHelper`,
142+
]);
143+
});
144+
});
145+
146+
describe('the workflow scan', () => {
147+
test('reads every entry on the multi-line xcodebuild invocation', () => {
148+
expect(
149+
parseSelectedTests(
150+
[
151+
' xcodebuild test-without-building \\',
152+
' -xctestrun "$XCTESTRUN_PATH" \\',
153+
` -only-testing:${TARGET}/RunnerTests/testOne \\`,
154+
` -only-testing:${TARGET}/RunnerTests/testTwo`,
155+
].join('\n'),
156+
),
157+
).toEqual([
158+
{ identifier: `${TARGET}/RunnerTests/testOne`, line: 3 },
159+
{ identifier: `${TARGET}/RunnerTests/testTwo`, line: 4 },
160+
]);
161+
});
162+
163+
test('leaves another target alone rather than guessing about sources it cannot see', () => {
164+
const report = buildReport(
165+
TARGET,
166+
source('extension RunnerTests {\n func testOne() {}\n}\n'),
167+
`-only-testing:SomeOtherTarget/OtherTests/testUnknown\n-only-testing:${TARGET}/RunnerTests/testOne`,
168+
);
169+
expect(report.unknown).toEqual([]);
170+
expect(report.selected).toHaveLength(2);
171+
});
172+
});
173+
174+
describe('the blind-parse guards', () => {
175+
test('an empty declaration scan fails instead of reporting a healthy list', () => {
176+
const report = buildReport(TARGET, source('// nothing here\n'), '-only-testing:a/b/c');
177+
expect(reportFailures(report).join('\n')).toContain('declaration scan is broken');
178+
});
179+
180+
test('an empty workflow scan fails instead of reporting a healthy list', () => {
181+
const report = buildReport(
182+
TARGET,
183+
source('extension RunnerTests {\n func testOne() {}\n}\n'),
184+
'',
185+
);
186+
expect(reportFailures(report).join('\n')).toContain('stopped filtering');
187+
});
188+
});

0 commit comments

Comments
 (0)