Skip to content

Commit 353c827

Browse files
committed
fix(ci): skip the runner server entry point in the nightly and validate both test flags
1 parent 1a9e5da commit 353c827

9 files changed

Lines changed: 625 additions & 159 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,11 @@ jobs:
172172
with: { gate: gate-manifest }
173173

174174
# 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
175+
# it claims. ios.yml runs 37 of the 154 runner XCTest methods through a hand-written
176176
# `-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).
177+
# an empty selection rather than an error, so a rename drops a test with no signal —
178+
# in both directions, since a typo in xctest-nightly.yml's `-skip-testing:` entry
179+
# re-arms a 24-hour hang. Parse-only, no Xcode (#1781 A7).
179180
- name: Check the PR XCTest selection still names real tests
180181
uses: ./.github/actions/run-gate
181182
with: { gate: xctest-selection }

.github/workflows/xctest-nightly.yml

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
name: XCTest Nightly
22

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.
3+
# The full iOS runner XCTest suite (#1781 A7). The target declares 154 addressable methods;
4+
# the PR lane (ios.yml) names 37 of them in a hand-written `-only-testing:` list, so 117 ran
5+
# nowhere at all. This lane drops that 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) — for 116 methods that nothing else executes.
68
#
79
# Under the lane rule in #1781:
810
#
9-
# - Catches: regressions in the ~116 runner-semantics tests the PR list leaves out —
11+
# - Catches: regressions in the 116 runner-semantics tests the PR list leaves out —
1012
# selector matching, snapshot traversal, command journal, sequence execution, tv remote,
11-
# keyboard, scroll/gesture policy. Nothing else executes them.
13+
# keyboard, scroll/gesture policy. Nothing else executes them. Two of the 154
14+
# (`…OnTvOS`) are behind `#if os(tvOS)` and so never execute on this lane's destination;
15+
# the honest iOS-reachable figure is 151.
1216
# - Evidence: the list has never been re-derived since it was written by hand; the tests it
1317
# omits have been dark for their whole life, so their current state is unmeasured. The
1418
# first red night is the evidence this lane exists to produce.
@@ -22,6 +26,11 @@ name: XCTest Nightly
2226
# Scheduled + manual only. It is deliberately NOT a PR gate: ios.yml is already over half of
2327
# this repo's CI spend (#1781 A9), and adding 116 tests to it would make that worse for a set
2428
# of tests whose flakiness on a shared runner is unknown.
29+
#
30+
# Note on `concurrency` below: it is keyed on `github.ref`, so a manual dispatch on the same
31+
# ref cancels an in-flight scheduled run. That is the intended trade — an operator asking for
32+
# a run wants the fresh one — but it means "dispatch while the nightly is running" loses the
33+
# nightly's result rather than queueing behind it.
2534

2635
on:
2736
schedule:
@@ -87,7 +96,21 @@ jobs:
8796
preferred-device-name: iPhone 17 Pro
8897

8998
# Same command as ios.yml's targeted step minus every `-only-testing:` flag, so the
90-
# xctestrun's own test plan decides what runs.
99+
# xctestrun's own test plan decides what runs — with one exception.
100+
#
101+
# `RunnerTests/testCommand` is not a test. It is the runner's server entry point: it
102+
# opens an NWListener and blocks in `XCTWaiter.wait(timeout: 24 * 60 * 60)` until a
103+
# client closes the session, which is exactly how production drives it
104+
# (src/platforms/apple/core/runner/runner-session.ts always passes it as the sole
105+
# `-only-testing:`). It compiles unconditionally — the `#if AGENT_DEVICE_RUNNER_UNIT_TESTS`
106+
# block ends well above it — so an unfiltered run reaches it in alphabetical order and
107+
# hangs the job until `timeout-minutes`. Its one escape hatch,
108+
# AGENT_DEVICE_RUNNER_NOOP_STARTUP, is an environment variable, and the comment above
109+
# the flag records that env plumbing into a simulator test process is not dependable;
110+
# `-skip-testing:` is the lever that works from here.
111+
#
112+
# A typo in that identifier silently re-arms the hang, so
113+
# `pnpm check:xctest-selection` validates `-skip-testing:` exactly like `-only-testing:`.
91114
- name: Run the full iOS runner XCTest suite
92115
run: |
93116
set -euo pipefail
@@ -97,50 +120,42 @@ jobs:
97120
xcodebuild test-without-building \
98121
-xctestrun "$XCTESTRUN_PATH" \
99122
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
123+
-skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \
100124
-resultBundlePath "$RESULT_BUNDLE_PATH"
101125
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
126+
# Best-effort and never the job's verdict on its own; the step below is what asserts.
127+
# `--compact` first because a red night's summary is the large one, and the job summary
128+
# has a 1 MiB cap — the plain form is the fallback if an Xcode version rejects the flag.
129+
- name: Extract the test-results summary
105130
if: always()
106131
run: |
107132
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"
133+
[ -d "$RESULT_BUNDLE_PATH" ] || exit 0
134+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" --compact \
135+
> "$RESULT_SUMMARY_PATH" 2>/dev/null ||
136+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" \
137+
> "$RESULT_SUMMARY_PATH" 2>/dev/null ||
138+
rm -f "$RESULT_SUMMARY_PATH"
139+
exit 0
120140
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
141+
# Reports the run AND is the lane's own liveness check. `xcodebuild` exits 0 when a
142+
# selection matches nothing, so "green" and "ran no tests" are the same log tail: a
143+
# build variant without the unit-test compile flag, an empty test plan, or a renamed
144+
# target would all read as a healthy night. Asserting the run executed tests is what
145+
# tells those apart. The failure list is capped so a badly red night cannot blow the
146+
# 1 MiB job-summary limit and lose the headline with it.
147+
- name: Report the run and assert it executed tests
126148
if: always()
127149
run: |
128150
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.'
151+
if [ ! -s "$RESULT_SUMMARY_PATH" ]; then
152+
echo 'No usable test-results summary was produced, so nothing here can show a test ran.'
131153
echo 'The suite step above failed before or during the run; read its log first.'
154+
echo '### iOS runner full XCTest suite' >> "$GITHUB_STEP_SUMMARY"
155+
echo 'No result-bundle summary; see the run log and the uploaded bundle.' >> "$GITHUB_STEP_SUMMARY"
132156
exit 1
133157
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
158+
node --experimental-strip-types scripts/xctest-run-summary.ts
144159
145160
- name: Upload result bundle
146161
if: always()

docs/agents/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +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 |
40+
| Runner XCTest methods (`apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/**`) | `pnpm check:xctest-selection`. `ios.yml` runs 37 of the 154 methods through a hand-written `-only-testing:` list and `xctest-nightly.yml` excludes one 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 |
4141
| 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 |
4242
| 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) |
4343
| `.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 |
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)