Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-apple-runner-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ runs:
id: source-hash
run: |
set -euo pipefail
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/patch-xcuitest-runner-icon.ts', 'scripts/write-xcuitest-cache-metadata.mjs', 'packages/platform-apple/src/runner/apple-runner-platform.ts', 'packages/platform-apple/src/runner/runner-cache-metadata.ts', 'packages/platform-apple/src/runner/runner-icon.ts', 'packages/platform-apple/src/runner/runner-xctestrun.ts', 'packages/platform-apple/src/runner/runner-xctestrun-products.ts', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT"
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/patch-xcuitest-runner-icon.ts', 'scripts/write-xcuitest-cache-metadata.mjs', 'packages/platform-apple/src/runner/**', '!packages/platform-apple/src/runner/__tests__/**', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT"
shell: bash

- name: Resolve Apple runner build variant
Expand Down
191 changes: 42 additions & 149 deletions .github/workflows/ios.yml

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,25 @@ jobs:
--report-junit
test/artifacts/replays-macos.junit.xml

# Clean-install verification may raise macOS local-network permission UI; keep it after replay.
- name: Select Apple bridge proof
if: github.event_name == 'pull_request'
id: bridge-impact
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch origin "$BASE_SHA" --depth=1 || echo 'Base fetch failed; bridge proof selection will fail open.'
node --experimental-strip-types scripts/apple-ci-impact.ts bridge

- name: Verify clean-installed Simulator snapshot bridge preparation and the fold-helper -Werror gate
if: github.event_name == 'pull_request' && steps.bridge-impact.outputs.run != 'false'
run: |
pnpm build
pnpm exec vitest run \
packages/platform-apple/src/snapshot-source/native-runtime.test.ts \
packages/platform-apple/src/foldable/fold-helper-cache.test.ts
pnpm check:package -- --verify-snapshot-bridge-preparation

- name: Upload macOS artifacts
if: always()
uses: ./.github/actions/upload-agent-device-artifacts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ extension RunnerTests {
/// against a second handwritten table: the decoder maps the integer the runner stamps, and #2726
/// shipped `runningBackground` and `runningBackgroundSuspended` reversed.
///
/// The host lane runs this on every PR, and `.github/workflows/ios.yml` lists it for the simulator
/// lane so the suspended case — compiled out of the macOS build below — is pinned on every PR too.
/// The host lane runs this on every PR; the simulator lane derives it from the platform branch
/// below so the suspended case, compiled out of the macOS build, is pinned on every PR too.
/// `packages/platform-apple/src/runner/__tests__/target-activation.test.ts` reads these calls back
/// and compares them with its decode table, so a table that drifts from them fails on any host
/// instead of waiting for a lane to report a mislabelled repair.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ extension RunnerTests {
// into the runner's temporary directory.
let fileName = URL(fileURLWithPath: message).lastPathComponent
XCTAssertTrue(message.hasSuffix(fileName), message)
#if os(iOS)
XCTAssertTrue(message.hasPrefix("tmp/"), message)
#elseif os(macOS)
XCTAssertTrue(message.hasPrefix("/"), message)
#endif
let storedPath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: storedPath)), pngData)
XCTAssertEqual(response.data?.screenshotMetadata?.pixelsPerPoint, 3)
Expand Down
2 changes: 2 additions & 0 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ provider, and coverage tests mock the typed HDC seam. Real validation is local h
Apple runner changes run `pnpm check:xctest-selection` and build the affected target. The source
`#if` guard is the XCTest lane classification — never maintain a second test-name list. Pure runner
decisions use the macOS host lane; iOS/XCTest semantics need a simulator lane.
The iOS PR lane derives platform-specific XCTests from Swift guards for runner changes;
nightly runs the full suite.

Local host-lane XCTest may need signing and automation permission:

Expand Down
156 changes: 156 additions & 0 deletions scripts/__tests__/apple-ci-impact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { expect, test } from 'vitest';
import { parse } from 'yaml';
import { selectAppleBridgeProof, selectIosXctests } from '../apple-ci-impact.ts';
import { selectChecks } from '../check-affected/model.ts';

const repoRoot = path.resolve(import.meta.dirname, '../..');

function cacheInputs(action: string): string[] {
const doc = parse(action) as { runs?: { steps?: Array<{ id?: string; run?: string }> } };
const sourceHash = doc.runs?.steps?.find((step) => step.id === 'source-hash')?.run ?? '';
const expressions = [...sourceHash.matchAll(/hashFiles\(([\s\S]*?)\)/g)];
expect(expressions.length).toBeGreaterThan(0);
return expressions.flatMap((expression) =>
[...expression[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!),
);
}

test('every runner build-cache input triggers the PR XCTest lane', () => {
const action = fs.readFileSync(
path.join(repoRoot, '.github/actions/setup-apple-runner-build/action.yml'),
'utf8',
);
expect(cacheInputs(action)).toContain('packages/platform-apple/src/runner/**');
expect(cacheInputs(action)).toContain('!packages/platform-apple/src/runner/__tests__/**');
const uncovered = (text: string) =>
cacheInputs(text)
.filter((input) => !input.startsWith('!'))
.filter((input) => {
const path = input.replace(/\*\*?$/, 'probe.ts');
const plan = selectChecks({ changedFiles: [path] });
return !plan.failOpen && !plan.checks.includes('swift-runner-ios');
});
expect(uncovered(action)).toEqual([]);
expect(
uncovered(action.replace('apple/runner/**', 'packages/platform-apple/src/snapshot-source/**')),
).toEqual(['packages/platform-apple/src/snapshot-source/**']);
expect(
uncovered(
action.replace(
'hashFiles(',
"hashFiles('packages/platform-apple/src/foldable/**',\n ",
),
),
).toEqual(['packages/platform-apple/src/foldable/**']);
});

test('the PR workflow applies the impact decision to the XCTest step', () => {
const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/ios.yml'), 'utf8');
expect(workflow).toContain('node --experimental-strip-types scripts/apple-ci-impact.ts xctest');
expect(workflow).toMatch(
/- name: Run targeted iOS runner XCTest regressions\n\s+id: ios-xctest\n\s+if: steps\.xctest-impact\.outputs\.run != 'false'/,
);
expect(workflow).toContain('if [ "$SELECTED" = \'true\' ] && [ "$OUTCOME" = \'skipped\' ]');
});

test('macOS clean-install proof follows live UI replay', () => {
const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/macos.yml'), 'utf8');
const replay = workflow.indexOf('- name: Run macOS integration test');
const proof = workflow.indexOf(
'- name: Verify clean-installed Simulator snapshot bridge preparation',
);
expect(replay).toBeGreaterThan(-1);
expect(proof).toBeGreaterThan(replay);
expect(workflow).toContain('node --experimental-strip-types scripts/apple-ci-impact.ts bridge');
expect(workflow).toContain("steps.bridge-impact.outputs.run != 'false'");
});

test('native runner and golden-table changes run XCTest; TypeScript runtime changes use live E2E', () => {
for (const file of [
'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift',
'apple/snapshot-presentation/Sources/Presenter.swift',
'packages/platform-apple/src/runner/runner-icon.ts',
'contracts/fixtures/scroll-gesture.json',
'.github/workflows/ios.yml',
'scripts/apple-ci-impact.ts',
]) {
expect(selectIosXctests('pull_request', [file]).run, file).toBe(true);
}
for (const file of [
'packages/platform-apple/src/snapshot-source/cache.ts',
'packages/platform-apple/src/runner/__tests__/runner-icon.test.ts',
'apple/fold-helper/fold-helper.c',
'test/integration/ios-simulator-e2e/live-runner.ts',
]) {
expect(selectIosXctests('pull_request', [file]), file).toMatchObject({ run: false });
}
});

test('pushes and uncertain diffs keep the full XCTest selection', () => {
expect(selectIosXctests('push', ['src/index.ts']).run).toBe(true);
expect(selectIosXctests('pull_request', null).run).toBe(true);
expect(selectIosXctests('pull_request', []).run).toBe(true);
expect(selectIosXctests('pull_request', ['src/index.ts', 'package.json']).run).toBe(true);
});

test('bridge proof runs for its owning sources and uncertain tooling changes', () => {
for (const file of [
'apple/snapshot-bridge/Bridge.c',
'apple/fold-helper/Helper.c',
'apple/new-native-module/Source.m',
'packages/platform-apple/src/snapshot-source/native-runtime.ts',
'packages/platform-apple/src/foldable/fold-helper-cache.ts',
'packages/platform-apple/src/new-module/runtime.ts',
'scripts/check-package.ts',
'.github/workflows/macos.yml',
]) {
expect(selectAppleBridgeProof([file]).run, file).toBe(true);
}
expect(selectAppleBridgeProof(['src/index.ts']).run).toBe(false);
expect(selectAppleBridgeProof(null).run).toBe(true);
expect(selectAppleBridgeProof([]).run).toBe(true);
});

test('a shallow PR merge still yields a known change set for both selectors', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'apple-ci-impact-'));
const source = path.join(root, 'source');
const shallow = path.join(root, 'shallow');
fs.mkdirSync(source);
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
try {
git(source, 'init', '-q', '-b', 'main');
git(source, 'config', 'user.email', 'test@example.com');
git(source, 'config', 'user.name', 'Test');
git(source, 'commit', '--allow-empty', '-qm', 'base');
const base = git(source, 'rev-parse', 'HEAD');
git(source, 'checkout', '-qb', 'feature');
fs.mkdirSync(path.join(source, 'src'));
fs.writeFileSync(path.join(source, 'src', 'feature.ts'), 'export const feature = true;\n');
git(source, 'add', '.');
git(source, 'commit', '-qm', 'feature');
git(source, 'checkout', '-q', 'main');
git(source, 'merge', '-q', '--no-ff', '-m', 'merge', 'feature');
git(root, 'clone', '-q', '--depth=1', '--branch', 'main', `file://${source}`, shallow);
git(shallow, 'fetch', '-q', 'origin', base, '--depth=1');
expect(git(shallow, 'rev-parse', '--is-shallow-repository')).toBe('true');
const select = (target: string) =>
execFileSync(
process.execPath,
['--experimental-strip-types', path.join(repoRoot, 'scripts/apple-ci-impact.ts'), target],
{
cwd: shallow,
encoding: 'utf8',
env: { ...process.env, BASE_SHA: base, GITHUB_EVENT_NAME: 'pull_request' },
},
);
expect(select('xctest')).toContain('skip;');
expect(select('bridge')).toContain('skip;');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
44 changes: 44 additions & 0 deletions scripts/__tests__/xctest-declarations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,50 @@ describe('the declaration scan', () => {
).toEqual([`${TARGET}/RunnerTests/testGolden`]);
});

test('discovers tests in indented extensions and new files without a name list', () => {
expect(
parseDeclaredTests(TARGET, [
{
file: 'UnitTests/NewScreenCaptureTests.swift',
text:
'#if os(iOS)\n' +
' extension RunnerTests {\n' +
' func testObservedScreenCapture() {}\n' +
' }\n' +
'#endif\n',
},
{
file: 'NewTests.swift',
text: ' extension RunnerTests {\n func testAnotherIndent() {}\n }\n',
},
]),
).toEqual([
`${TARGET}/RunnerTests/testAnotherIndent`,
`${TARGET}/RunnerTests/testObservedScreenCapture`,
]);
});

test('fails closed when a test-shaped declaration cannot be classified', () => {
expect(() => parseDeclaredTests(TARGET, source('func testOutsideAType() {}\n'))).toThrow(
'RunnerTests+Fixture.swift:1: unrecognized XCTest declaration',
);
expect(() =>
parseDeclaredTests(
TARGET,
source('extension RunnerTests {\n func testGeneric<T>() {}\n}\n'),
),
).toThrow('RunnerTests+Fixture.swift:2: unrecognized XCTest declaration');
expect(() =>
parseDeclaredTests(TARGET, source('extension RunnerTests { func testInline() {} }\n')),
).toThrow('RunnerTests+Fixture.swift:1: unrecognized XCTest declaration');
expect(() =>
parseDeclaredTests(
TARGET,
source('extension RunnerTests {\n @available(iOS 17, *) func testAttributed() {}\n}\n'),
),
).toThrow('RunnerTests+Fixture.swift:2: unrecognized XCTest declaration');
});

test('attributes each declared method to the platforms that compile it', () => {
expect(
parseDeclaredTestsByPlatform(
Expand Down
Loading
Loading