Skip to content

perf(ios): shorten smoke critical path with affected XCTest selection - #2896

Merged
thymikee merged 6 commits into
mainfrom
perf/ios-smoke-critical-path
Sep 24, 2026
Merged

thymikee merged 6 commits into
mainfrom
perf/ios-smoke-critical-path

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Shorten selected iOS smoke runs by deriving their XCTest set from Swift guards and the shared affected-check model. The selector finds 64 iOS-specific tests on current main; macOS covers shared host tests and nightly keeps the full iOS suite. Runner source cache inputs and test ownership are derived from source rather than maintained test-name lists. The macOS bridge proof runs after live UI replay and only when its owning Apple sources change.

Use a two-tree Git diff so both selectors work in GitHub's shallow PR merge checkout. Remove three unasserted Settings captures and run the short pan replay before fixture E2E. This touches 15 files; no public API changes.

Validation

0baea4109cc79075ef2a7211d82bd3c4e8636bc5: pnpm check:affected --run passed all runnable checks after rebasing onto merged #2901/#2902. pnpm check:xctest-selection found 316 declared methods: 64 in the iOS PR lane, 254 in the macOS host lane, 314 nightly, none unreachable. A shallow merge-shaped checkout test exercises the real CLI selector for both iOS XCTest and macOS bridge skip decisions; it failed before the two-tree fix and passes now. The earlier iOS smoke run passed on 28a26fe, but its selector failed open. Exact-head CI is pending. A real GitHub skip path needs an unrelated PR diff after this selector lands.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB 0 B
Package (unpacked) 4.80 MB 4.80 MB 0 B
Package (download) 1.44 MB 1.44 MB +8 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.3 ms 28.3 ms +0.0 ms
CLI --help 79.3 ms 80.4 ms +1.1 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f9ce9ab. Running the runner XCTests only when their inputs change should cut iOS smoke time on PRs that do not touch the runner, and unknown diffs fail open.

The new RUNNER_INPUTS list in scripts/ios-xctest-impact.ts repeats ownership rules that scripts/check-affected/model.ts already defines and tests (the swift-runner-ios predicate, which also covers any *.swift file and contracts/fixtures/**). Could this gate ask "does this change touch swift-runner-ios ownership" instead of keeping its own prefix list? Then one place decides which paths need the Swift runner build. That needs either widening model.ts beyond local feedback, or moving the ownership predicates into a small helper that both use. If you chose the separate list on purpose, what made the shared predicate unsuitable?

Not blocking: uncoveredRunnerCacheInputs reads only the first hashFiles(...) match in the action file, and changedPathsFromGit uses a two-dot diff while the moved bridge check uses three-dot. Both are fine today.

I read the scripts and workflow diffs but did not run the new tests. Both Smoke Tests jobs were still running at review time, and this PR edits the workflows they run, so a failure there would likely be related. The next step is your answer on the shared ownership predicate.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear structural pass (on the CI/skip logic and the selection model, not "does the job run"). What is right: RUNNER_INPUTS is a clean declarative prefix/exact input set (no if path matches ... chain), it genuinely fails open on non-PR / null / empty change sets, and choosing to test cache-inputs ⊆ selection-inputs rather than a test-name list is the correct invariant to reach for; the reorder that puts the cheap gesture pan canary before the long fixture E2E is sound.

Three structural concerns, all one theme — put the input sets where the repo already enforces input sets, and make the boundary fail the same way the module does. The replay edit (removing three unasserted captures, reorder) I checked and have no finding on: test/screenshots is untracked so nothing automatic proved those, and snapshot -i stays proven by the fixture E2E tier.

Smaller notes (not inline):

  • uncoveredRunnerCacheInputs — the load-bearing safety argument for the whole skip — rests on a single-line hashFiles\(([^\n]+)\) regex (a reformat across lines or a second hashFiles breaks it) and asserts only cache-inputs ⊆ selection-inputs, so anything missing from both lists (pnpm-workspace.yaml, packages/*/package.json) is unprovable and the gap got worse post-PR (a missed build input now also skips, not just runs-vs-stale). Parse the action with the yaml dep you already use in scripts/gate/workflows.ts, and assert the set both ways against one declared build-input list.
  • changedPaths === null (diff failed) and [] (nothing changed) share one reason string ("could not be established") that prints into the step summary — a genuinely empty change set reports as an infra failure. Keep both fail-open but give distinct reasons; that string is the operator's only audit trail for a skipped suite.
  • Two-dot vs three-dot: this gate uses git diff baseSha HEAD while the bridge proof uses "$BASE_SHA"...HEAD — two definitions of "changed" in one PR. Pick merge-base semantics in both, or share one change-set helper.
  • Entry guard deviates from siblings: process.argv[1]?.endsWith('/ios-xctest-impact.ts') vs the import.meta.url === pathToFileURL(...) idiom every other script in scripts/ uses; and the test resolves workflow paths from CWD while its neighbour anchors on import.meta.dirname.

Comment thread .github/workflows/ios.yml Outdated
pnpm check:package -- --verify-snapshot-bridge-preparation

- name: Run targeted iOS runner XCTest regressions
if: steps.xctest-impact.outputs.run == 'true'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module is written to fail open (non-PR / null / empty all return run: true), but this consumer fails closed: if: steps.xctest-impact.outputs.run == 'true' treats an absent output as skip. Rename the step id, move the step into a composite action or a separate selector job the way mutation-affected.yml does, or land a script edit that drops the output write, and you get '' == 'true' -> the whole ~110-test runner suite silently skipped with the job rendering green and no step summary at all. That is the one state where you have neither a test run nor a signal. Express the same decision as != 'false' so "no decision" is broad and the YAML agrees with the module's contract, and add a terminal assertion (in the macos.yml "reads as red rather than as a smaller green" style) that fails when the impact step reported a runner input changed but the XCTest step was skipped. Today the only thing between this lane and a silent full-suite skip is a hand-written regex over the workflow text.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 28a26fe7e8: the XCTest step now runs unless the selector explicitly outputs false. It has a step ID, and an if: always() assertion fails the job if selection reported true but the XCTest step was skipped. The generated -only-testing arguments also fail the step if generation fails or returns no tests.

Comment thread scripts/ios-xctest-impact.ts Outdated
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';

const RUNNER_INPUTS = [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the repo's second hand-maintained answer to "which changed inputs own the iOS runner XCTest lane", and it is the one that ISN'T machine-enforced. scripts/check-affected/model.ts already declares path->check ownership (own:swift -> swift-runner-ios, own:xctest-selection) and check:gate-manifest / scripts/gate/routing.ts hold that model against the workflow paths-ignore lists, both ways, over every tracked path. RUNNER_INPUTS is exempt from that enforcement, so nothing keeps it complete: a new input that feeds the runner build (a script, a package path) added to the lane but missed here silently skips the XCTests it was supposed to gate — your fail-open promise is only as good as this list, and completeness is exactly what no gate checks here. AGENTS.md: don't add a guard that reconstructs another source of truth. Can this ask check-affected / selectChecks (or import an exported owner rule from it), so gate-manifest covers the step the way it covers the lane triggers, and docs/agents/testing.md points at the one declaration instead of restating the rule?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 28a26fe7e8: the separate RUNNER_INPUTS list is gone. scripts/apple-ci-impact.ts asks the shared selectChecks model whether swift-runner-ios is owned by the diff. I widened that owning rule for the Apple runner source subtree, and the gate manifest and tests enforce new paths. The PR XCTest method set is generated from Swift guards, so no test-name list needs updating either.

Comment thread .github/workflows/macos.yml Outdated
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Verify clean-installed Simulator snapshot bridge preparation and the fold-helper -Werror gate

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR correctly concludes that "should this proof run for this diff" belongs in a declared, tested input-set module — and then moves the other instance of that exact predicate into this job still written as an inline ~9-path git diff --quiet glob chain with a hand-tuned echo skip reason. Post-PR you have one predicate as a tested TS module and one as YAML shell globs, and the relocation makes the globs look canonical rather than pending; the two lists (bridge/fold sources, check-package.ts, size-report-*) will be edited by different people and drift. Migrate it the same way — one module exporting the proof's input set plus a fail-open select…, called as a one-line node step from both workflows, covered beside ios-xctest-impact.test.ts. Deleting the shell globs is the win here.

Two smaller things in this same step: the git fetch origin "$BASE_SHA" here is unguarded while the ios.yml copy has || echo '...fail open' — same command, opposite failure policy (with Actions' bash -eo pipefail a transient fetch failure reddens the whole macOS lane for an infra hiccup); and adding .github/workflows/ios.yml + macos.yml to a list whose skip branch prints "Snapshot bridge and fold-helper sources are unchanged" is self-referential and prints a wrong reason on a workflow-only change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 28a26fe7e8: the inline glob chain is gone. The shared apple-ci-impact.ts selector handles bridge proof decisions, covers new apple/ and platform-Apple source paths, and fails open when fetching or reading the PR diff fails. The macOS proof runs after live replay, and its reason now names the actual selection outcome.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 08c10f3. The new commit only moves a step in .github/workflows/macos.yml and updates its test, and all checks pass on this head, including macOS Smoke Tests.

The two questions from the last review are still open, because scripts/ios-xctest-impact.ts did not change. Can this gate reuse the swift-runner-ios ownership rule from scripts/check-affected/model.ts instead of its own RUNNER_INPUTS list? And should the steps.xctest-impact.outputs.run == 'true' condition fail open like the module does, so a missing output runs the tests instead of skipping them? A short answer or a change for each is enough.

@thymikee

Copy link
Copy Markdown
Member Author

Resolved the review notes in 28a26fe7e8. The cache-input test now parses the action YAML and all hashFiles expressions, including multiline formatting; production runner files enter the cache hash through a broad subtree glob. Null and empty diffs have distinct fail-open reasons. Both Apple selectors use a three-dot diff and the standard import.meta.url entry guard; workflow tests resolve from import.meta.dirname. The selector derives 59 PR XCTests from Swift guards/platform-dependent bodies and fails on missing or ambiguous declarations. The exact-head local gate passed; GitHub CI is pending.

@thymikee

Copy link
Copy Markdown
Member Author

This follow-up is on 28a26fe. The change-set detection this PR depends on is still broken, so the perf behavior it exists to deliver still cannot happen in CI.

changedPathsFromGit in https://github.com/callstack/agent-device/blob/28a26fe/scripts/apple-ci-impact.ts#L53 runs a three-dot diff (BASE_SHA...HEAD). On a pull_request event, HEAD is the shallow merge commit and the base is fetched at depth 1, so git finds no merge base and exits non-zero; the function returns null, which both the iOS XCTest selector and the macOS bridge proof read as run:true. Run 36007154705 shows exactly this on the exact head. Both selectors fail open on every PR, so the XCTest step and the bridge proof never skip and the critical path is never actually shortened. Could this be fixed by diffing the merge commit against its first parent (git diff --name-only HEAD^1 HEAD with fetch-depth: 2), or by keeping a two-dot BASE_SHA HEAD diff that only over-selects when the base has moved? Either way, one changedPathsFromGit needs to serve both the xctest and bridge targets, and it needs a test that runs it against a shallow merge-shaped checkout.

No CI run on any head has shown the skip route firing. The selection step at https://github.com/callstack/agent-device/blob/28a26fe/.github/workflows/ios.yml#L101, and the new "Assert iOS XCTest selection was honored" guard with SELECTED=false, have never executed against a real skip decision; the PR body also defers this to a later run. Once the change-set fix lands, this needs two runs: one on an unrelated diff where "Select iOS runner XCTests" logs "iOS XCTest: skip; the affected-check model selected no iOS runner build" and the XCTest step is skipped while the job stays green, and one on a runner-touching diff where the step logs "run; XCTest input changed: " and executes the generated 59 -only-testing ids.

I did not run the vitest suites or the local gate at this head; I ran check-xctest-selection.ts --ios-pr-tests against a copy of the head tree only. The selector classifies a shared test by the guards in the test body, so could a shared test that reaches a production-side #if os(iOS) branch end up running only on macOS in PR CI? I sampled four of the 69 dropped tests and they're pure, but I didn't trace the rest. apple-ci-impact.ts:43 calls changedPaths.find on a value TypeScript still types as nullable; it can't fire at runtime since checkOwnership returns early on null, so that's not a correctness bug, just worth a look while the file is open.

The iOS Smoke Tests job was still queued at 28a26fe, so there's no log to attribute there yet. macOS Smoke Tests passed at this head, but that pass is the bridge proof failing open described above, not a real exercise of the feature. This PR rewrites the ios.yml selection step, the generated -only-testing args, and the always() selection assert, so any failure in that job should be treated as likely related. There are no conflicts. The next thing needed is the change-set diff fix at scripts/apple-ci-impact.ts:53, followed by one CI run that actually takes the skip route.

@thymikee
thymikee force-pushed the perf/ios-smoke-critical-path branch from 28a26fe to 0baea41 Compare September 24, 2026 15:38
@thymikee

Copy link
Copy Markdown
Member Author

Confirmed. The macOS log's fail-open message came from using a three-dot diff in GitHub's shallow merge checkout. At 0baea4109c, both selectors use git diff --name-only -z BASE_SHA HEAD, which compares the two available trees without requiring a merge base. I planted a shallow merge-shaped checkout test that invokes the real selector CLI for both XCTest and bridge: it was red with the old diff and now returns skip for an unrelated source change. A runner-source change still selects XCTest through the shared affected-check model.

I also traced the dropped shared-test case you raised. testFileScreenshotResponseWritesTheImageAndStillReportsItsDisplay calls a production iOS-specific path. It now asserts the iOS tmp/ result under an #if os(iOS) guard, so source discovery adds it automatically. Current selection has 64 iOS PR tests and no unreachable methods. This is a concrete indirect case, not a claim that the scanner proves every arbitrary transitive production branch.

The rebased exact head passed pnpm check:affected --run; its CI is pending. This PR's cumulative workflow/selector diff necessarily selects the run route, so a real GitHub skip decision needs an unrelated PR after this lands. The previous iOS pass was a fail-open run and is not skip-route evidence. The nullable changedPaths.find type is harmless here: checkOwnership returns early for null and empty paths, though I agree the type could be narrower.

@thymikee

Copy link
Copy Markdown
Member Author

The code looks ready at 0baea41. The follow-up on the earlier findings (#2896 (comment)) is resolved and nothing new turned up. Not blocking: you could open a throwaway draft PR based on this branch with an unrelated src/ change to confirm the skip route still logs "iOS XCTest: skip; the affected-check model selected no iOS runner build" and stays green, since this PR's own diff always takes the run route and the pull_request trigger has no branch filter — take it or leave it.

I did not run the vitest suites (apple-ci-impact.test.ts, xctest-selection.test.ts) or the local gate at this head; I reproduced the shallow three-dot and two-dot behavior by hand with git. I reviewed only the logical delta: commit 0baea41 plus the range-diff hunk in a19fd96 that drops upstream-added ids from the hand-written list. A shared test with no guard in its own body that reaches a production #if os(iOS) branch would run only on macOS in PR CI; the nightly full iOS suite still covers it. I sampled about 8 of the 70 dropped tests (snapshot plan, private-AX pin, custom actions, raw plan, backend capabilities) and all were pure or took an explicit availableBackends or penalized argument — I did not trace the rest, and the scanner does not enforce the transitive case, as the PR description notes.

The iOS Smoke Tests job (ios.yml smoke-ios) is still running. Its route overlaps this diff directly through the Select iOS runner XCTests step, the generated 64-id -only-testing list, and the always() selection assert, so a failure there should be treated as likely related to this PR. That run on 0baea41 needs to finish green while executing the generated 64-id -only-testing list before this can merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee added this pull request to stack #2924 September 24, 2026 16:46
@thymikee
thymikee merged commit 2c7856d into main Sep 24, 2026
19 checks passed
@thymikee
thymikee deleted the perf/ios-smoke-critical-path branch September 24, 2026 16:47
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-24 16:47 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up to the requested live skip proof: the disposable #2923 produced a successful iOS job on fa4c326220. Its selector logged iOS XCTest: skip; the affected-check model selected no iOS runner build; the targeted XCTest step was skipped and the selection assertion passed.

Measured runner time was 18m55s, versus 24m44s for #2896's successful exact-head iOS job: 5m49s saved in this pair. The probe spent extra time on retries (Settings passed on attempt 3; gesture on attempt 2), so this is one noisy sample, not a stable throughput claim. Queue time was 1m54s versus 29m01s and is reported separately.

#2923 was rebased onto main after this PR merged; its fresh iOS/macOS jobs are pending to confirm both selectors on the new exact head. The probe will be closed after those results are recorded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant