diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d4a5f360..ada0c3da 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -163,6 +163,16 @@ jobs: run: | pnpm nx run-many -t build --projects="packages/*" + # Packs prebuilt XCTest agent artifacts into the platform package and + # fails if it cannot, so this job exercises the artifact path consumers + # get from a published package rather than only the build fallback. + - name: Pack XCTest agent artifacts + working-directory: packages/platform-ios + env: + HARNESS_REQUIRE_XCTEST_PREBUILDS: '1' + run: | + node scripts/pack-xctest-agent-artifacts.mjs + - name: Restore app from cache id: cache-app-restore uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 @@ -463,6 +473,16 @@ jobs: run: | pnpm nx run-many -t build --projects="packages/*" + # Packs prebuilt XCTest agent artifacts into the platform package and + # fails if it cannot, so this job exercises the artifact path consumers + # get from a published package rather than only the build fallback. + - name: Pack XCTest agent artifacts + working-directory: packages/platform-ios + env: + HARNESS_REQUIRE_XCTEST_PREBUILDS: '1' + run: | + node scripts/pack-xctest-agent-artifacts.mjs + - name: Restore app from cache id: cache-app-restore uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/.gitignore b/.gitignore index 2ecdf12f..b21ea997 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,8 @@ yarn-error.log # testing /coverage .harness/ +# Prebuilt XCTest agent artifacts, packed by the platform-apple `prepack` script +/packages/platform-ios/xctest-agent-prebuilds/ vite.config.*.timestamp* vitest.config.*.timestamp* diff --git a/.nx/version-plans/version-plan-1792238400000.md b/.nx/version-plans/version-plan-1792238400000.md new file mode 100644 index 00000000..efdb5bdd --- /dev/null +++ b/.nx/version-plans/version-plan-1792238400000.md @@ -0,0 +1,7 @@ +--- +__default__: minor +--- + +iOS runs no longer compile the permission helper from scratch. Harness now uses the prebuilt XCTest agent that ships with the Apple platform package, so a run reaches a ready agent in about a second instead of waiting on an Xcode build, and CI no longer needs to cache that build. The shipped agent works from Xcode 26 up to the Xcode version a release was packed with; on any other Xcode, Harness builds it locally as before. Set `HARNESS_IOS_XCTEST_PREBUILT_ROOT` to point at artifacts packed elsewhere, or `HARNESS_SKIP_XCTEST_PREBUILDS=1` to always build locally. + +Physical devices now sign a shipped artifact locally instead of building one. A runnable device build has to carry your own certificate and a profile that lists your device, so Harness takes the unsigned artifact and re-signs a private copy with a certificate picked by matching your profile against your keychain — offline, in about a second. It selects a profile only when that profile covers the agent's bundle ids and your device and has not expired, and otherwise builds locally as before, which remains the path that can reach the Developer Portal. If your account cannot issue a profile covering the agent's identifiers, set `device.codeSign.bundleIdPrefix` to rewrite them to your own prefix; if your keychain holds several development certificates, set `device.codeSign.signingIdentity` to one SHA-1, since `codesign` rejects the ambiguous name. diff --git a/packages/platform-ios/package.json b/packages/platform-ios/package.json index 79389986..52a65164 100644 --- a/packages/platform-ios/package.json +++ b/packages/platform-ios/package.json @@ -23,6 +23,9 @@ "zod": "^3.25.67", "tslib": "^2.3.0" }, + "scripts": { + "prepack": "node scripts/pack-xctest-agent-artifacts.mjs" + }, "devDependencies": { "@types/yargs": "^17.0.32" }, diff --git a/packages/platform-ios/scripts/pack-xctest-agent-artifacts.mjs b/packages/platform-ios/scripts/pack-xctest-agent-artifacts.mjs new file mode 100644 index 00000000..87b3debd --- /dev/null +++ b/packages/platform-ios/scripts/pack-xctest-agent-artifacts.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Packs prebuilt XCTest agent artifacts into this package, so consumers on a +// supported Xcode skip `xcodebuild build-for-testing`. One artifact per +// destination serves every supported Xcode, so packing uses the selected one. +// +// Runs as `prepack`, which npm and pnpm trigger during `nx release publish`. +// By then `nx release version` has already bumped package.json, so artifacts +// are stamped with the version they ship under. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const modulePath = path.join(packageRoot, 'dist', 'xctest-agent-prebuilt.js'); + +// Both destinations ship. Simulator artifacts help every iOS run, while device +// artifacts help only the subset that runs on hardware, and +// cost roughly half again on the tarball (1.0 MB to 1.6 MB compressed). They ship +// anyway because signing them locally is the only way a device run gets a fast +// path at all, and an inert implementation would not be worth shipping. Consumers +// who never touch hardware pay one larger download and nothing at runtime: the +// provider declines before doing any work. Pack a narrower set with +// HARNESS_XCTEST_PREBUILD_DESTINATIONS=simulator. +const DEFAULT_DESTINATIONS = ['simulator', 'device']; +const DESTINATIONS_ENV = 'HARNESS_XCTEST_PREBUILD_DESTINATIONS'; + +const getDefaultDestinations = () => { + const configured = process.env[DESTINATIONS_ENV]?.trim(); + + if (!configured) { + return DEFAULT_DESTINATIONS; + } + + return configured + .split(',') + .map((destination) => destination.trim()) + .filter(Boolean); +}; + +const isSupportedDestination = (destination) => + destination === 'simulator' || destination === 'device'; + +const parseDestinations = (argv) => { + const destinations = []; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === '--destination') { + destinations.push(argv[(index += 1)]); + continue; + } + + throw new Error(`Unknown argument: ${argument}`); + } + + const requested = + destinations.length > 0 ? destinations : getDefaultDestinations(); + + for (const destination of requested) { + if (!isSupportedDestination(destination)) { + throw new Error( + `Unsupported destination '${destination}'. Expected 'simulator' or 'device'.` + ); + } + } + + return [...new Set(requested)]; +}; + +const canBuildForIos = () => { + try { + execFileSync('xcodebuild', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +// Reason an unpackable release is still releasable: consumers just build the +// agent locally, exactly as they did before these artifacts existed. So a pack +// failure warns instead of aborting the release. A job that must produce +// artifacts sets HARNESS_REQUIRE_XCTEST_PREBUILDS=1 to make failures fatal. +const isRequired = process.env.HARNESS_REQUIRE_XCTEST_PREBUILDS === '1'; + +const getSkipReason = () => { + if (process.env.HARNESS_SKIP_XCTEST_PREBUILDS === '1') { + return 'HARNESS_SKIP_XCTEST_PREBUILDS is set.'; + } + + if (!fs.existsSync(modulePath)) { + return `${modulePath} is missing. Build this package before packing.`; + } + + if (!canBuildForIos()) { + return 'xcodebuild is unavailable on this machine.'; + } + + return null; +}; + +// Argument mistakes are unambiguous (and cannot come from `npm publish`, which +// passes no arguments), so they fail loudly regardless of how strict this run +// is. Environment limitations are handled below. +let destinations; + +try { + destinations = parseDestinations(process.argv.slice(2)); +} catch (error) { + console.error( + `Unable to pack XCTest agent artifacts: ${ + error instanceof Error ? error.message : String(error) + }` + ); + process.exit(1); +} + +const skipReason = getSkipReason(); + +if (skipReason && isRequired) { + throw new Error(`Unable to pack XCTest agent artifacts: ${skipReason}`); +} + +const describeFailure = (error) => + error instanceof Error ? error.message : String(error); + +if (skipReason) { + console.warn(`Skipping prebuilt XCTest agent artifacts: ${skipReason}`); +} else { + const { getPrebuiltRootPath, packXCTestAgentArtifacts } = await import( + pathToFileURL(modulePath).href + ); + + // Start from nothing, so the tarball carries exactly what this run packed + // and never an artifact left behind by an earlier pack. + fs.rmSync(getPrebuiltRootPath(packageRoot), { recursive: true, force: true }); + + for (const destination of destinations) { + try { + const result = await packXCTestAgentArtifacts({ + destination, + packageRoot, + }); + + console.log( + `Packed prebuilt XCTest agent artifacts ${[ + `destination=${result.entry.destination}`, + `xcode=${result.entry.xcodeVersion}${ + result.entry.xcodeBuild ? ` (${result.entry.xcodeBuild})` : '' + }`, + `sdk=${result.entry.sdkVersion}`, + `version=${result.entry.agentVersion}`, + ].join(' ')}` + ); + } catch (error) { + // A failed destination costs only its own artifact; the other still + // ships. + const message = `${destination}: ${describeFailure(error)}`; + + if (isRequired) { + throw new Error(`Unable to pack XCTest agent artifacts for ${message}`); + } + + console.warn(`Skipping prebuilt XCTest agent artifacts for ${message}`); + } + } +} diff --git a/packages/platform-ios/src/__tests__/config.test.ts b/packages/platform-ios/src/__tests__/config.test.ts new file mode 100644 index 00000000..ec620d5e --- /dev/null +++ b/packages/platform-ios/src/__tests__/config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { ApplePhysicalDeviceCodeSignSchema } from '../config.js'; + +const withPrefix = (bundleIdPrefix: string) => + ApplePhysicalDeviceCodeSignSchema.safeParse({ + bundleIdPrefix, + teamId: 'TESTTEAM01', + }); + +describe('device.codeSign.bundleIdPrefix', () => { + it('is optional', () => { + expect( + ApplePhysicalDeviceCodeSignSchema.safeParse({ teamId: 'TESTTEAM01' }) + .success + ).toBe(true); + }); + + it.each(['com.example', 'com.example.app', 'dev.chmal', 'a.b'])( + 'accepts the reverse-DNS prefix %s', + (bundleIdPrefix) => { + expect(withPrefix(bundleIdPrefix).success).toBe(true); + } + ); + + // The prefix is pasted into bundle ids and signed into installed bundles, so a + // value with spaces or path separators would produce an id that fails at + // install time, far from the typo that caused it. + it.each([ + 'com example', + 'dev chmal.harness-agent', + 'com/example', + 'com.example..app', + '.com.example', + 'com.example.', + 'com', + '', + ])('rejects the invalid prefix %s', (bundleIdPrefix) => { + expect(withPrefix(bundleIdPrefix).success).toBe(false); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-artifacts.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-artifacts.test.ts new file mode 100644 index 00000000..f8c61d26 --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-artifacts.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + resolveXCTestAgentArtifacts, + type XCTestAgentArtifacts, + type XCTestAgentProvider, +} from '../xctest-agent-artifacts.js'; + +const context = { + destination: 'simulator' as const, + projectRoot: '/tmp/project', +}; + +const artifactsFrom = ( + source: XCTestAgentArtifacts['source'] +): XCTestAgentArtifacts => ({ + launch: { + derivedDataPath: `/tmp/derived/${source}`, + kind: 'xctestrun', + xctestrunPath: `/tmp/derived/${source}/agent.xctestrun`, + }, + source, +}); + +const providerReturning = ( + id: XCTestAgentArtifacts['source'] +): XCTestAgentProvider => ({ + id, + prepare: vi.fn(() => artifactsFrom(id)), +}); + +const providerDeclining = (id: string): XCTestAgentProvider => ({ + id, + prepare: vi.fn(() => null), +}); + +describe('resolveXCTestAgentArtifacts', () => { + it('returns artifacts from the first provider that provides them', async () => { + const first = providerReturning('prebuilt'); + const second = providerReturning('build'); + + const artifacts = await resolveXCTestAgentArtifacts([first, second], context); + + expect(artifacts.source).toBe('prebuilt'); + expect(artifacts.launch.derivedDataPath).toBe('/tmp/derived/prebuilt'); + expect(first.prepare).toHaveBeenCalledTimes(1); + expect(second.prepare).not.toHaveBeenCalled(); + }); + + it('falls through to the next provider when one declines', async () => { + const declining = providerDeclining('prebuilt'); + const providing = providerReturning('build'); + + const artifacts = await resolveXCTestAgentArtifacts( + [declining, providing], + context + ); + + expect(artifacts.launch.derivedDataPath).toBe('/tmp/derived/build'); + expect(declining.prepare).toHaveBeenCalledTimes(1); + expect(providing.prepare).toHaveBeenCalledTimes(1); + }); + + it('passes the requested context to providers', async () => { + const provider = providerReturning('build'); + const signal = new AbortController().signal; + + await resolveXCTestAgentArtifacts([provider], { + destination: 'device', + projectRoot: '/tmp/other', + signing: { teamId: 'TESTTEAM01' }, + signal, + }); + + expect(provider.prepare).toHaveBeenCalledWith({ + destination: 'device', + projectRoot: '/tmp/other', + signing: { teamId: 'TESTTEAM01' }, + signal, + }); + }); + + it('fails listing every provider that declined when none provides artifacts', async () => { + await expect( + resolveXCTestAgentArtifacts( + [providerDeclining('prebuilt'), providerDeclining('cache')], + context + ) + ).rejects.toThrow(/no provider supplied artifacts \(tried prebuilt, cache\)/); + }); + + it('propagates a provider failure instead of falling back', async () => { + const failing: XCTestAgentProvider = { + id: 'prebuilt', + prepare: vi.fn(async () => { + throw new Error('artifact checksum mismatch'); + }), + }; + const fallback = providerReturning('build'); + + await expect( + resolveXCTestAgentArtifacts([failing, fallback], context) + ).rejects.toThrow('artifact checksum mismatch'); + expect(fallback.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-prebuilt.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-prebuilt.test.ts new file mode 100644 index 00000000..f7010ab5 --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-prebuilt.test.ts @@ -0,0 +1,683 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock('@react-native-harness/tools', async () => { + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); + + return { + ...actual, + spawn: mocks.spawn, + }; +}); + +import { + createPrebuiltXCTestAgentProvider, + getPrebuiltIndexPath, + packXCTestAgentArtifacts, + readPrebuiltIndex, + type XCTestAgentPrebuiltIndex, +} from '../xctest-agent-prebuilt.js'; +import { getAgentProjectSourcesHash } from '../xctest-agent-build.js'; + +const xcodeVersion = '26.4.1'; +const xcodeBuild = '17F103'; +const simulatorSdkVersion = '26.4'; +const packageVersion = JSON.parse( + fs.readFileSync( + path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..', + 'package.json' + ), + 'utf8' + ) +) as { version: string }; + +let tempRoot = ''; +let packageRoot = ''; +let projectRoot = ''; + +const mockXcodebuild = (options?: { + sdkVersion?: string; + xcodeVersion?: string; +}) => { + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.join(' ') === '-version') { + return Promise.resolve({ + stdout: `Xcode ${ + options?.xcodeVersion ?? xcodeVersion + }\nBuild version ${xcodeBuild}`, + }); + } + + if ( + file === 'xcodebuild' && + args?.join(' ') === '-version -sdk iphonesimulator SDKVersion' + ) { + return Promise.resolve({ + stdout: options?.sdkVersion ?? simulatorSdkVersion, + }); + } + + if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { + const buildProductsPath = path.join( + args[args.indexOf('-derivedDataPath') + 1], + 'Build', + 'Products' + ); + fs.mkdirSync(path.join(buildProductsPath, 'Debug-iphonesimulator'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + buildProductsPath, + 'HarnessXCTestAgent_iphonesimulator26.4.xctestrun' + ), + 'packed xctestrun' + ); + fs.writeFileSync( + path.join(buildProductsPath, 'Debug-iphonesimulator', 'agent'), + 'agent binary' + ); + fs.mkdirSync( + path.join( + buildProductsPath, + 'Debug-iphonesimulator', + 'agent.dSYM', + 'DWARF' + ), + { recursive: true } + ); + fs.writeFileSync( + path.join( + buildProductsPath, + 'Debug-iphonesimulator', + 'agent.dSYM', + 'DWARF', + 'agent' + ), + 'debug symbols' + ); + + return Promise.resolve({ stdout: '' }); + } + + return Promise.resolve({ stdout: '' }); + }); +}; + +const writeFakeArtifact = (options: { + directoryName: string; + xctestrunName?: string; + withDebugSymbols?: boolean; +}) => { + const buildProductsPath = path.join( + packageRoot, + 'xctest-agent-prebuilds', + options.directoryName, + 'Build', + 'Products' + ); + const xctestrunName = + options.xctestrunName ?? 'agent_iphonesimulator.xctestrun'; + + fs.mkdirSync(path.join(buildProductsPath, 'Debug-iphonesimulator'), { + recursive: true, + }); + fs.writeFileSync( + path.join(buildProductsPath, xctestrunName), + `xctestrun for ${options.directoryName}` + ); + fs.writeFileSync( + path.join( + buildProductsPath, + 'Debug-iphonesimulator', + 'HarnessXCTestAgent.app' + ), + 'agent binary' + ); + + if (options.withDebugSymbols) { + fs.mkdirSync( + path.join(buildProductsPath, 'Debug-iphonesimulator', 'agent.dSYM'), + { recursive: true } + ); + fs.writeFileSync( + path.join( + buildProductsPath, + 'Debug-iphonesimulator', + 'agent.dSYM', + 'DWARF' + ), + 'symbols' + ); + fs.mkdirSync( + path.join( + buildProductsPath, + 'Debug-iphonesimulator', + 'agent.swiftmodule' + ), + { recursive: true } + ); + } + + return xctestrunName; +}; + +const writeIndex = (index: XCTestAgentPrebuiltIndex) => { + const indexPath = getPrebuiltIndexPath(packageRoot); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2)); + + return indexPath; +}; + +const makeEntry = (options: { + agentVersion?: string; + buildInputsHash?: string; + directoryName: string; + sdkVersion?: string; + xcodeVersion?: string; + xctestrunRelativePath: string; +}) => ({ + agentVersion: options.agentVersion ?? packageVersion.version, + buildInputsHash: options.buildInputsHash ?? getAgentProjectSourcesHash(), + destination: 'simulator' as const, + directoryName: options.directoryName, + sdkVersion: options.sdkVersion ?? simulatorSdkVersion, + xctestrunRelativePath: options.xctestrunRelativePath, + xcodeBuild, + xcodeVersion: options.xcodeVersion ?? xcodeVersion, +}); + +const contextFor = (root = projectRoot) => ({ + destination: 'simulator' as const, + projectRoot: root, +}); + +describe('prebuilt XCTest agent artifacts', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Same as in the resign suite: these tests are about the shipped artifacts, + // so an inherited skip flag would disable the thing under test. + delete process.env.HARNESS_SKIP_XCTEST_PREBUILDS; + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-harness-prebuilt-')); + packageRoot = path.join(tempRoot, 'package'); + projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + mockXcodebuild(); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { force: true, recursive: true }); + }); + + describe('provider', () => { + it('declines when the package ships no artifact index', async () => { + const provider = createPrebuiltXCTestAgentProvider({ packageRoot }); + + await expect(provider.prepare(contextFor())).resolves.toBeNull(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('unpacks a matching artifact into the project cache and points at its xctestrun', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + const artifacts = await createPrebuiltXCTestAgentProvider({ + packageRoot, + }).prepare(contextFor()); + + expect(artifacts?.source).toBe('prebuilt'); + expect(artifacts?.launch.kind).toBe('xctestrun'); + if (artifacts?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + expect(artifacts.launch.xctestrunPath).toBe( + path.join( + projectRoot, + '.harness', + 'cache', + 'xctest-agent-prebuilt-simulator-abc123', + 'Build', + 'Products', + xctestrunName + ) + ); + expect(fs.readFileSync(artifacts.launch.xctestrunPath, 'utf8')).toBe( + 'xctestrun for simulator-abc123' + ); + }); + + it('leaves the packaged artifact untouched while running from the copy', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + const packagedXCTestRunPath = path.join( + packageRoot, + 'xctest-agent-prebuilds', + 'simulator-abc123', + 'Build', + 'Products', + xctestrunName + ); + + const artifacts = await createPrebuiltXCTestAgentProvider({ + packageRoot, + }).prepare(contextFor()); + if (artifacts?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + + fs.writeFileSync( + artifacts.launch.xctestrunPath, + 'written by a test session' + ); + + expect(fs.existsSync(packagedXCTestRunPath)).toBe(true); + expect(fs.readFileSync(packagedXCTestRunPath, 'utf8')).toBe( + 'xctestrun for simulator-abc123' + ); + }); + + it('reuses an artifact it already unpacked', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + const provider = createPrebuiltXCTestAgentProvider({ packageRoot }); + + const first = await provider.prepare(contextFor()); + if (first?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + fs.writeFileSync(first.launch.xctestrunPath, 'kept from the first run'); + + const second = await provider.prepare(contextFor()); + if (second?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + + expect(second.launch.xctestrunPath).toBe(first.launch.xctestrunPath); + expect(fs.readFileSync(second.launch.xctestrunPath, 'utf8')).toBe( + 'kept from the first run' + ); + }); + + it('uses an artifact packed with a different Xcode and SDK', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + sdkVersion: '27.0', + xcodeVersion: '27.0', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + const artifacts = await createPrebuiltXCTestAgentProvider({ + packageRoot, + }).prepare(contextFor()); + + expect(artifacts?.source).toBe('prebuilt'); + }); + + it('declines on an Xcode newer than the one that packed the artifact', async () => { + mockXcodebuild({ xcodeVersion: '28.0' }); + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + xcodeVersion: '27.1', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('declines on an Xcode older than the supported minimum', async () => { + mockXcodebuild({ xcodeVersion: '16.4' }); + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('declines when the agent sources no longer match the packed ones', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + buildInputsHash: 'stale-agent-hash', + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('declines when the artifact belongs to another package version', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'simulator-abc123', + }); + writeIndex({ + entries: [ + makeEntry({ + agentVersion: '0.0.1', + directoryName: 'simulator-abc123', + xctestrunRelativePath: xctestrunName, + }), + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('declines when another destination owns the only artifact', async () => { + const xctestrunName = writeFakeArtifact({ + directoryName: 'device-abc123', + }); + writeIndex({ + entries: [ + { + ...makeEntry({ + directoryName: 'device-abc123', + xctestrunRelativePath: xctestrunName, + }), + destination: 'device', + }, + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('declines when the indexed artifact is missing from the package', async () => { + writeIndex({ + entries: [ + makeEntry({ + directoryName: 'simulator-missing', + xctestrunRelativePath: 'agent_iphonesimulator.xctestrun', + }), + ], + schemaVersion: 1, + }); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + }); + + it('ignores a malformed index', async () => { + const indexPath = getPrebuiltIndexPath(packageRoot); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, 'not json'); + + await expect( + createPrebuiltXCTestAgentProvider({ packageRoot }).prepare(contextFor()) + ).resolves.toBeNull(); + expect(readPrebuiltIndex(indexPath)).toBeNull(); + }); + }); + + describe('packing', () => { + it('writes an index entry and a stripped product tree', async () => { + const result = await packXCTestAgentArtifacts({ + agentVersion: '9.9.9', + destination: 'simulator', + packageRoot, + projectRoot, + }); + + expect(result.entry).toMatchObject({ + agentVersion: '9.9.9', + buildInputsHash: getAgentProjectSourcesHash(), + destination: 'simulator', + sdkVersion: simulatorSdkVersion, + xcodeVersion, + }); + expect( + fs.existsSync( + path.join( + result.artifactDirectoryPath, + 'Build', + 'Products', + 'HarnessXCTestAgent_iphonesimulator26.4.xctestrun' + ) + ) + ).toBe(true); + expect( + fs.existsSync( + path.join( + result.artifactDirectoryPath, + 'Build', + 'Products', + 'Debug-iphonesimulator', + 'agent' + ) + ) + ).toBe(true); + expect( + fs.existsSync( + path.join( + result.artifactDirectoryPath, + 'Build', + 'Products', + 'Debug-iphonesimulator', + 'agent.dSYM' + ) + ) + ).toBe(false); + }); + + it('replaces the artifact for a destination and removes the old directory', async () => { + await packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }); + mockXcodebuild({ xcodeVersion: '26.5' }); + const second = await packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }); + + const index = readPrebuiltIndex(getPrebuiltIndexPath(packageRoot)); + expect(index?.entries).toHaveLength(1); + expect(index?.entries[0]?.directoryName).toBe(second.entry.directoryName); + // The superseded artifact would otherwise ship in the tarball unused. + expect( + fs.readdirSync(path.join(packageRoot, 'xctest-agent-prebuilds')).sort() + ).toEqual([second.entry.directoryName, 'index.json'].sort()); + }); + + it('builds into a fresh folder instead of reusing a local build', async () => { + await packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }); + + const buildArgs = mocks.spawn.mock.calls.find( + (call) => call[1]?.[0] === 'build-for-testing' + )?.[1] as string[]; + const derivedDataPath = + buildArgs[buildArgs.indexOf('-derivedDataPath') + 1]; + + expect(derivedDataPath.startsWith(projectRoot)).toBe(false); + expect(fs.existsSync(derivedDataPath)).toBe(false); + expect(fs.existsSync(path.join(projectRoot, '.harness'))).toBe(false); + }); + + it.each([ + [ + 'a path from the packing machine', + 'Debug-iphonesimulator/agent', + () => `source at ${os.homedir()}/agent.swift`, + ], + [ + 'an embedded provisioning profile', + 'Debug-iphonesimulator/embedded.mobileprovision', + () => 'profile', + ], + ])('refuses artifacts that carry %s', async (_, relativePath, contents) => { + const baseImplementation = mocks.spawn.getMockImplementation(); + mocks.spawn.mockImplementation(async (file: string, args?: string[]) => { + const result = await baseImplementation?.(file, args); + + if (args?.[0] === 'build-for-testing') { + fs.writeFileSync( + path.join( + args[args.indexOf('-derivedDataPath') + 1], + 'Build', + 'Products', + relativePath + ), + contents() + ); + } + + return result; + }); + + await expect( + packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }) + ).rejects.toThrow(/Refusing to pack/); + expect(readPrebuiltIndex(getPrebuiltIndexPath(packageRoot))).toBeNull(); + expect( + fs.existsSync(path.join(packageRoot, 'xctest-agent-prebuilds')) && + fs.readdirSync(path.join(packageRoot, 'xctest-agent-prebuilds')) + .length > 0 + ).toBe(false); + }); + + it('keeps artifacts of different destinations side by side', async () => { + await packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }); + writeFakeArtifact({ directoryName: 'device-abc123' }); + const indexPath = getPrebuiltIndexPath(packageRoot); + const index = readPrebuiltIndex(indexPath); + writeIndex({ + entries: [ + ...(index?.entries ?? []), + { + ...makeEntry({ + directoryName: 'device-abc123', + xctestrunRelativePath: 'agent_iphonesimulator.xctestrun', + }), + destination: 'device', + }, + ], + schemaVersion: 1, + }); + + expect(readPrebuiltIndex(indexPath)?.entries).toHaveLength(2); + }); + + it('produces artifacts the provider accepts without rebuilding', async () => { + const packed = await packXCTestAgentArtifacts({ + destination: 'simulator', + packageRoot, + projectRoot, + }); + mocks.spawn.mockClear(); + + const artifacts = await createPrebuiltXCTestAgentProvider({ + packageRoot, + }).prepare(contextFor(path.join(tempRoot, 'consumer'))); + + expect(artifacts?.source).toBe('prebuilt'); + expect( + mocks.spawn.mock.calls.filter( + (call) => call[1]?.[0] === 'build-for-testing' + ) + ).toHaveLength(0); + expect( + (artifacts?.launch as { xctestrunPath: string }).xctestrunPath + ).toContain(packed.entry.directoryName); + }); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-profile-lookup.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-profile-lookup.test.ts new file mode 100644 index 00000000..f62798ce --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-profile-lookup.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock('@react-native-harness/tools', async () => { + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); + + return { ...actual, spawn: mocks.spawn }; +}); + +import { findProvisioningProfile } from '../xctest-agent-resign.js'; + +const teamId = 'TESTTEAM01'; +const device = '00008150-0000000000000000'; + +// The profile's certificate list is matched against the signing identity, so a +// known certificate whose fingerprint the test can compute keeps the keychain +// out of it. Self-signed and generated for these tests only. +const certificate = Buffer.from( + [ + 'MIIBfTCCASKgAwIBAgIUTeGS56+1GMVWk0JJ46uFrUcV4YAwCgYIKoZIzj0EAwIw', + 'IzEhMB8GA1UEAwwYSGFybmVzcyBUZXN0IENlcnRpZmljYXRlMCAXDTI2MTAxODE1', + 'MTcyOFoYDzIxMjYwOTI0MTUxNzI4WjAjMSEwHwYDVQQDDBhIYXJuZXNzIFRlc3Qg', + 'Q2VydGlmaWNhdGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQldmHMtw5UdQrC', + 'Mq2sPkH8McXFALDp5XQMN42E9ZGkJP/gMHdAqzkLGuexvtIYry2UtLbaxQIS+5yn', + 'kLVoNEqYozIwMDAdBgNVHQ4EFgQUWu/u3cfX8U0spNhKZM7JEExO7dkwDwYDVR0T', + 'AQH/BAUwAwEB/zAKBggqhkjOPQQDAgNJADBGAiEA33wF0yFIBMSSyl5EppAn+AOZ', + 'DIFLLIQI7dNepX1mrpMCIQDsu1E3rRvTW1NfUGN5WY7AOlwsZEP5Ev0S/qH3K69r', + 'Fg==', + ].join(''), + 'base64' +); +const certificateHash = new crypto.X509Certificate( + certificate +).fingerprint.replaceAll(':', ''); + +let tempRoot = ''; + +const writeProfile = (options: { devices: string[]; teamId?: string }) => { + const profilePath = path.join( + tempRoot, + `${options.devices.join('-') || 'none'}.mobileprovision` + ); + // Stand-in bytes: decoding is mocked, so only the decoded payload matters. + fs.writeFileSync(profilePath, 'not-really-cms'); + mocks.spawn.mockImplementation(async (file: string) => { + if (file !== 'security') { + throw new Error(`Unexpected command ${file}`); + } + + return { + stdout: ` + + + NameTest profile + UUIDtest-uuid + TeamIdentifier${ + options.teamId ?? teamId + } + ExpirationDate2099-01-01T00:00:00Z + ProvisionedDevices${options.devices + .map((value) => `${value}`) + .join('')} + Entitlements + application-identifier${teamId}.* + + DeveloperCertificates${certificate.toString( + 'base64' + )} +`, + }; + }); + + return profilePath; +}; + +const search = (profile: string) => + findProvisioningProfile({ + deviceId: device, + profile, + signingIdentity: certificateHash, + teamId, + }); + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-profile-test-')); + mocks.spawn.mockReset(); +}); + +describe('a profile named by path', () => { + it('is accepted when it lists the target device', async () => { + const profile = writeProfile({ devices: [device] }); + + await expect(search(profile)).resolves.toMatchObject({ + name: 'Test profile', + }); + }); + + it('is declined when it does not list the target device', async () => { + // Naming a profile must not skip the device check: the profile is still what + // the device installs, so an unlisted device would fail later as an install + // error instead of falling back to building. + const profile = writeProfile({ devices: ['some-other-device'] }); + + await expect(search(profile)).resolves.toBeNull(); + }); + + it('is declined when it belongs to another team', async () => { + const profile = writeProfile({ devices: [device], teamId: 'OTHERTEAM1' }); + + await expect(search(profile)).resolves.toBeNull(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-resign-identity.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-resign-identity.test.ts new file mode 100644 index 00000000..7be9951e --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-resign-identity.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const findIdentity = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ + spawnSync: (...args: unknown[]) => findIdentity(...args), +})); + +const REVOKED = 'B'.repeat(40); +const USABLE = 'A'.repeat(40); + +/** + * `security find-identity -v` lists an unusable identity and annotates it rather + * than leaving it out, so a revoked certificate is parsed like any other unless + * the trailing annotation is honoured. Getting this wrong costs a signing failure + * partway through a run, or worse, names a revoked certificate as the remedy. + */ +const findIdentityOutput = `\ + 1) ${REVOKED} "Apple Development: Revoked Person (TEAM000001)" (CSSMERR_TP_CERT_REVOKED) + 2) ${USABLE} "Apple Development: Working Person (TEAM000002)" + 2 valid identities found +`; + +const loadResignModule = async () => { + vi.resetModules(); + findIdentity.mockReset(); + findIdentity.mockImplementation(() => ({ + status: 0, + stdout: Buffer.from(findIdentityOutput), + })); + + return import('../xctest-agent-resign.js'); +}; + +afterEach(() => { + vi.resetModules(); +}); + +describe('choosing a codesign identity', () => { + it('ignores an identity the keychain reports as revoked', async () => { + const { resolveSigningIdentity } = await loadResignModule(); + + const result = resolveSigningIdentity({ + profile: { + applicationIdentifier: 'TESTTEAM01.*', + developerCertificateHashes: [REVOKED], + name: 'profile', + path: '', + provisionedDevices: [], + teamId: 'TESTTEAM01', + uuid: 'uuid', + }, + }); + + expect(result.identity).not.toBe(REVOKED); + expect(result.error).toContain('no usable'); + }); + + it('prefers the usable certificate when a profile also names a revoked one', async () => { + const { resolveSigningIdentity } = await loadResignModule(); + + const result = resolveSigningIdentity({ + profile: { + applicationIdentifier: 'TESTTEAM01.*', + developerCertificateHashes: [REVOKED, USABLE], + name: 'profile', + path: '', + provisionedDevices: [], + teamId: 'TESTTEAM01', + uuid: 'uuid', + }, + }); + + expect(result).toEqual({ identity: USABLE }); + }); + + it('never suggests a revoked certificate as the explicit identity to set', async () => { + const { resolveSigningIdentity } = await loadResignModule(); + + // Two *usable* certificates are genuinely ambiguous and must be reported. + findIdentity.mockImplementation(() => ({ + status: 0, + stdout: Buffer.from( + ` 1) ${REVOKED} "Apple Development: A (T1)" (CSSMERR_TP_CERT_REVOKED)\n` + + ` 2) ${USABLE} "Apple Development: B (T2)"\n` + + ` 3) ${'C'.repeat(40)} "Apple Development: C (T3)"\n` + ), + })); + + const result = resolveSigningIdentity({ + profile: { + applicationIdentifier: 'TESTTEAM01.*', + developerCertificateHashes: [REVOKED, USABLE, 'C'.repeat(40)], + name: 'profile', + path: '', + provisionedDevices: [], + teamId: 'TESTTEAM01', + uuid: 'uuid', + }, + }); + + expect(result.error).toContain(USABLE); + expect(result.error).toContain('C'.repeat(40)); + expect(result.error).not.toContain(REVOKED); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-resign.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-resign.test.ts new file mode 100644 index 00000000..4ab1df98 --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-resign.test.ts @@ -0,0 +1,869 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock('@react-native-harness/tools', async () => { + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); + + return { + ...actual, + spawn: mocks.spawn, + }; +}); + +import { + createResignedDeviceXCTestAgentProvider, + getAgentApplicationIdentifiers, + resignXCTestAgentArtifacts, + type ProvisioningProfile, +} from '../xctest-agent-resign.js'; +import { + getPrebuiltIndexPath, + type XCTestAgentPrebuiltIndex, +} from '../xctest-agent-prebuilt.js'; +import { getAgentProjectSourcesHash } from '../xctest-agent-build.js'; + +const teamId = 'TESTTEAM01'; +const device = '00008150-0000000000000000'; +const identity = 'A'.repeat(40); + +const profile: ProvisioningProfile = { + applicationIdentifier: `${teamId}.*`, + developerCertificateHashes: [identity], + name: 'Wildcards for TESTTEAM01', + path: '', + provisionedDevices: [device], + teamId, + uuid: 'profile-uuid-1', +}; + +let tempRoot = ''; +let derivedDataPath = ''; +let xctestrunPath = ''; +let appPath = ''; +let runnerPath = ''; +let pluginPath = ''; + +const writeBundle = (bundlePath: string, bundleId: string) => { + fs.mkdirSync(bundlePath, { recursive: true }); + fs.writeFileSync( + path.join(bundlePath, 'Info.plist'), + ` + + + +\tCFBundleIdentifier +\t${bundleId} + + +` + ); +}; + +const readPlist = (plistPath: string, keyPath: string) => { + const result = spawnSync('plutil', [ + '-extract', + keyPath, + 'raw', + '-o', + '-', + plistPath, + ]); + + return result.status === 0 ? result.stdout.toString().trim() : null; +}; + +const readPlistJson = (plistPath: string, keyPath: string) => { + const result = spawnSync('plutil', [ + '-extract', + keyPath, + 'json', + '-o', + '-', + plistPath, + ]); + + return result.status === 0 + ? (JSON.parse(result.stdout.toString()) as unknown) + : null; +}; + +const mockCodesign = () => { + const signed: string[] = []; + + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'codesign' && args?.[0] === '--verify') { + return Promise.resolve({ stdout: '' }); + } + + if (file === 'codesign') { + const targetPath = args?.length ? String(args[args.length - 1]) : ''; + + signed.push(targetPath); + + // Entitlements live in a directory the implementation removes once + // signing finishes, so their contents are captured while they exist. + if (args?.includes('--entitlements')) { + const entitlementsPath = args[args.indexOf('--entitlements') + 1]; + const contents = fs.readFileSync(String(entitlementsPath), 'utf8'); + + entitlementSnapshot.set(targetPath, contents); + } + + return Promise.resolve({ stdout: '' }); + } + + return Promise.resolve({ stdout: '' }); + }); + + return signed; +}; + +/** + * Builds the tree `xcodebuild build-for-testing` leaves behind, including the + * `.xctestrun`'s real nesting under `TestConfigurations`/`TestTargets`, which is + * where the runner and app-under-test paths live. + */ +const writeArtifacts = () => { + const productsPath = path.join(derivedDataPath, 'Build', 'Products'); + const debugPath = path.join(productsPath, 'Debug-iphoneos'); + + appPath = path.join(debugPath, 'HarnessXCTestAgent.app'); + runnerPath = path.join(debugPath, 'HarnessXCTestAgentUITests-Runner.app'); + pluginPath = path.join( + runnerPath, + 'PlugIns', + 'HarnessXCTestAgentUITests.xctest' + ); + + writeBundle(appPath, 'com.callstackincubator.HarnessXCTestAgent'); + writeBundle( + runnerPath, + 'com.callstackincubator.HarnessXCTestAgentUITests.xctrunner' + ); + writeBundle(pluginPath, 'com.callstackincubator.HarnessXCTestAgentUITests'); + + xctestrunPath = path.join( + productsPath, + 'HarnessXCTestAgent_HarnessXCTestAgent_iphoneos27.0-arm64.xctestrun' + ); + fs.writeFileSync( + xctestrunPath, + ` + + + +\tTestConfigurations +\t +\t\t +\t\t\tIsEnabled +\t\t\t +\t\t\tName +\t\t\tTest Scheme Action +\t\t\tTestTargets +\t\t\t +\t\t\t\t +\t\t\t\t\tIsEnabled +\t\t\t\t\t +\t\t\t\t\tTestHostPath +\t\t\t\t\t__TESTROOT__/Debug-iphoneos/HarnessXCTestAgentUITests-Runner.app +\t\t\t\t\tTestBundlePath +\t\t\t\t\t__TESTHOST__/PlugIns/HarnessXCTestAgentUITests.xctest +\t\t\t\t\tUITargetAppPath +\t\t\t\t\t__TESTROOT__/Debug-iphoneos/HarnessXCTestAgent.app +\t\t\t\t\tTestHostBundleIdentifier +\t\t\t\t\tcom.callstackincubator.HarnessXCTestAgentUITests.xctrunner +\t\t\t\t\tBundleIdentifiersForCrashReportEmphasis +\t\t\t\t\t +\t\t\t\t\t\tcom.callstackincubator.HarnessXCTestAgent +\t\t\t\t\t\tcom.callstackincubator.HarnessXCTestAgentUITests +\t\t\t\t\t\tcom.apple.dt.unit.testtarget +\t\t\t\t\t +\t\t\t\t +\t\t\t +\t\t +\t\t + + +`.replace('\t\t\n', '\n') + ); +}; + +/** Entitlement file contents, keyed by the bundle they were signed into. */ +const entitlementSnapshot = new Map(); + +const codesignCalls = () => + mocks.spawn.mock.calls.filter( + (call) => call[0] === 'codesign' && call[1]?.[0] === '--force' + ); + +/** The bundle path a `codesign` invocation targets, i.e. its last argument. */ +const signedTarget = (call: unknown[]) => + Array.isArray(call[1]) && call[1].length > 0 + ? String(call[1][call[1].length - 1]) + : ''; + +const entitlementsUsedBy = (bundlePath: string) => + entitlementSnapshot.get(bundlePath) ?? null; + +describe('re-signing prebuilt XCTest agent artifacts for a device', () => { + beforeEach(() => { + vi.clearAllMocks(); + // These tests exercise the shipped artifacts, so a skip flag inherited from + // the developer's shell would disable the very thing under test. + delete process.env.HARNESS_SKIP_XCTEST_PREBUILDS; + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-harness-resign-')); + profile.path = path.join(tempRoot, 'signing.mobileprovision'); + fs.writeFileSync(profile.path, 'profile bytes'); + derivedDataPath = path.join(tempRoot, 'derived'); + fs.mkdirSync(derivedDataPath, { recursive: true }); + writeArtifacts(); + }); + + afterEach(() => { + entitlementSnapshot.clear(); + fs.rmSync(tempRoot, { force: true, recursive: true }); + }); + + describe('bundle resolution', () => { + it('signs the bundles the xctestrun points at, innermost first', async () => { + const signed = mockCodesign(); + + await resignXCTestAgentArtifacts({ + profile, + xctestrunPath, + }); + + expect(signed).toEqual([pluginPath, runnerPath, appPath]); + }); + + it('signs frameworks embedded by Xcode 26 before the bundles holding them', async () => { + const embedded = [ + path.join(runnerPath, 'Frameworks', 'XCTest.framework'), + path.join(runnerPath, 'Frameworks', 'libXCTestSwiftSupport.dylib'), + ]; + for (const embeddedPath of embedded) { + fs.mkdirSync(path.dirname(embeddedPath), { recursive: true }); + fs.writeFileSync(path.join(runnerPath, 'Frameworks', 'Info.plist'), ''); + if (embeddedPath.endsWith('.framework')) { + fs.mkdirSync(embeddedPath, { recursive: true }); + } else { + fs.writeFileSync(embeddedPath, 'dylib'); + } + } + mockCodesign(); + + await resignXCTestAgentArtifacts({ profile, xctestrunPath }); + + const calls = codesignCalls(); + const embeddedCall = calls[0]?.[1] as string[]; + + // Signed in one invocation, without entitlements, ahead of the runner. + expect(embeddedCall.slice(-2).sort()).toEqual([...embedded].sort()); + expect(embeddedCall).not.toContain('--entitlements'); + expect(calls.slice(1).map(signedTarget)).toEqual([ + pluginPath, + runnerPath, + appPath, + ]); + }); + + it('embeds the profile in the bundles that carry entitlements', async () => { + mockCodesign(); + const profilePath = path.join(tempRoot, 'profile.mobileprovision'); + fs.writeFileSync(profilePath, 'profile bytes'); + + await resignXCTestAgentArtifacts({ + profile: { ...profile, path: profilePath }, + xctestrunPath, + }); + + expect( + fs + .readFileSync(path.join(runnerPath, 'embedded.mobileprovision')) + .toString() + ).toBe('profile bytes'); + expect( + fs + .readFileSync(path.join(appPath, 'embedded.mobileprovision')) + .toString() + ).toBe('profile bytes'); + }); + + it('gives the runner a keychain group and the app-under-test none', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + profile, + xctestrunPath, + }); + + const runnerEntitlements = entitlementsUsedBy(runnerPath); + const appEntitlements = entitlementsUsedBy(appPath); + + expect(runnerEntitlements).toContain( + `application-identifier${teamId}.com.callstackincubator.HarnessXCTestAgentUITests.xctrunner` + ); + expect(runnerEntitlements).toContain('keychain-access-groups'); + expect(runnerEntitlements).toContain('get-task-allow'); + expect(appEntitlements).toContain( + `application-identifier${teamId}.com.callstackincubator.HarnessXCTestAgent` + ); + expect(appEntitlements).not.toContain('keychain-access-groups'); + }); + + it('signs the nested XCTest bundle without entitlements', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + profile, + xctestrunPath, + }); + + const pluginCall = codesignCalls().find( + (call) => signedTarget(call) === pluginPath + ); + + expect(pluginCall?.[1]).not.toContain('--entitlements'); + }); + + it('verifies the signed runner strictly', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + profile, + xctestrunPath, + }); + + expect( + mocks.spawn.mock.calls.find( + (call) => call[0] === 'codesign' && call[1]?.[0] === '--verify' + )?.[1] + ).toEqual(['--verify', '--deep', '--strict', runnerPath]); + }); + + it('rejects artifacts whose xctestrun points at a missing runner', async () => { + mockCodesign(); + fs.rmSync(runnerPath, { recursive: true }); + + await expect( + resignXCTestAgentArtifacts({ profile, xctestrunPath }) + ).rejects.toThrow(/incomplete/); + }); + }); + + describe('bundle id remapping', () => { + it('rewrites the Info.plists and every bundle id the xctestrun names', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + bundleIdPrefix: 'com.example.consumer', + profile, + xctestrunPath, + }); + + const targetKey = 'TestConfigurations.0.TestTargets.0'; + + expect( + readPlist(path.join(appPath, 'Info.plist'), 'CFBundleIdentifier') + ).toBe('com.example.consumer.HarnessXCTestAgent'); + expect( + readPlist(path.join(runnerPath, 'Info.plist'), 'CFBundleIdentifier') + ).toBe('com.example.consumer.HarnessXCTestAgentUITests.xctrunner'); + expect( + readPlist(path.join(pluginPath, 'Info.plist'), 'CFBundleIdentifier') + ).toBe('com.example.consumer.HarnessXCTestAgentUITests'); + // The session launches the runner by the id in the xctestrun, so a stale + // value there means the bundle it looks for was never installed. + expect( + readPlist(xctestrunPath, `${targetKey}.TestHostBundleIdentifier`) + ).toBe('com.example.consumer.HarnessXCTestAgentUITests.xctrunner'); + expect( + readPlistJson( + xctestrunPath, + `${targetKey}.BundleIdentifiersForCrashReportEmphasis` + ) + ).toEqual([ + 'com.example.consumer.HarnessXCTestAgent', + 'com.example.consumer.HarnessXCTestAgentUITests', + 'com.apple.dt.unit.testtarget', + ]); + }); + + it('derives entitlements from the remapped ids', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + bundleIdPrefix: 'com.example.consumer', + profile, + xctestrunPath, + }); + + expect(entitlementsUsedBy(runnerPath)).toContain( + `${teamId}.com.example.consumer.HarnessXCTestAgentUITests.xctrunner` + ); + expect(entitlementsUsedBy(appPath)).toContain( + `${teamId}.com.example.consumer.HarnessXCTestAgent` + ); + }); + + it('accepts a prefix with or without a trailing dot', async () => { + mockCodesign(); + + await resignXCTestAgentArtifacts({ + bundleIdPrefix: 'com.example.consumer.', + profile, + xctestrunPath, + }); + + expect( + readPlist(path.join(appPath, 'Info.plist'), 'CFBundleIdentifier') + ).toBe('com.example.consumer.HarnessXCTestAgent'); + }); + + it('leaves already-remapped artifacts alone', async () => { + mockCodesign(); + fs.writeFileSync( + path.join(appPath, 'Info.plist'), + fs + .readFileSync(path.join(appPath, 'Info.plist'), 'utf8') + .replace( + 'com.callstackincubator.HarnessXCTestAgent', + 'com.example.consumer.HarnessXCTestAgent' + ) + ); + + await resignXCTestAgentArtifacts({ + bundleIdPrefix: 'com.example.consumer', + profile, + xctestrunPath, + }); + + expect( + readPlist(path.join(appPath, 'Info.plist'), 'CFBundleIdentifier') + ).toBe('com.example.consumer.HarnessXCTestAgent'); + }); + }); + + describe('profile application identifiers', () => { + it('asks about the app-under-test and runner, team-qualified', () => { + expect( + getAgentApplicationIdentifiers({ + teamId, + }) + ).toEqual([ + `${teamId}.com.callstackincubator.HarnessXCTestAgent`, + `${teamId}.com.callstackincubator.HarnessXCTestAgentUITests.xctrunner`, + ]); + }); + + it('asks about the remapped ids when a prefix is configured', () => { + expect( + getAgentApplicationIdentifiers({ + bundleIdPrefix: 'com.example.consumer', + teamId, + }) + ).toEqual([ + `${teamId}.com.example.consumer.HarnessXCTestAgent`, + `${teamId}.com.example.consumer.HarnessXCTestAgentUITests.xctrunner`, + ]); + }); + }); + + describe('provider', () => { + const xcodeVersion = '27.0'; + const xcodeBuild = '17A400'; + const deviceSdkVersion = '27.0'; + const packageJson = JSON.parse( + fs.readFileSync( + path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..', + 'package.json' + ), + 'utf8' + ) + ) as { version: string }; + + let packageRoot = ''; + let projectRoot = ''; + let profileLookup: ReturnType = vi.fn(); + + const writeDeviceArtifact = (directoryName: string) => { + const productsPath = path.join( + packageRoot, + 'xctest-agent-prebuilds', + directoryName, + 'Build', + 'Products' + ); + const debugPath = path.join(productsPath, 'Debug-iphoneos'); + + writeBundle( + path.join(debugPath, 'HarnessXCTestAgent.app'), + 'com.callstackincubator.HarnessXCTestAgent' + ); + writeBundle( + path.join(debugPath, 'HarnessXCTestAgentUITests-Runner.app'), + 'com.callstackincubator.HarnessXCTestAgentUITests.xctrunner' + ); + writeBundle( + path.join( + debugPath, + 'HarnessXCTestAgentUITests-Runner.app', + 'PlugIns', + 'HarnessXCTestAgentUITests.xctest' + ), + 'com.callstackincubator.HarnessXCTestAgentUITests' + ); + + const xctestrunName = 'HarnessXCTestAgent_iphoneos.xctestrun'; + + fs.writeFileSync( + path.join(productsPath, xctestrunName), + fs.readFileSync(xctestrunPath, 'utf8') + ); + + return xctestrunName; + }; + + const writeDeviceIndex = (options?: { + directoryName?: string; + xcodeVersion?: string; + }) => { + const directoryName = options?.directoryName ?? 'device-abc123'; + const xctestrunName = writeDeviceArtifact(directoryName); + const indexPath = getPrebuiltIndexPath(packageRoot); + + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync( + indexPath, + JSON.stringify( + { + entries: [ + { + agentVersion: packageJson.version, + buildInputsHash: getAgentProjectSourcesHash(), + destination: 'device', + directoryName, + sdkVersion: deviceSdkVersion, + xctestrunRelativePath: xctestrunName, + xcodeBuild, + xcodeVersion: options?.xcodeVersion ?? xcodeVersion, + }, + ], + schemaVersion: 1, + } satisfies XCTestAgentPrebuiltIndex, + null, + 2 + ) + ); + + return { directoryName, xctestrunName }; + }; + + const makeProvider = () => + createResignedDeviceXCTestAgentProvider({ + findProfile: profileLookup, + packageRoot, + }); + + const deviceContext = () => ({ + deviceId: device, + destination: 'device' as const, + projectRoot, + signing: { signingIdentity: identity, teamId }, + }); + + beforeEach(() => { + packageRoot = path.join(tempRoot, 'package'); + projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + profileLookup = vi.fn(async () => profile); + mockCodesign(); + + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.join(' ') === '-version') { + return Promise.resolve({ + stdout: `Xcode ${xcodeVersion}\nBuild version ${xcodeBuild}`, + }); + } + + if ( + file === 'xcodebuild' && + args?.join(' ') === '-version -sdk iphoneos SDKVersion' + ) { + return Promise.resolve({ stdout: deviceSdkVersion }); + } + + return Promise.resolve({ stdout: '' }); + }); + }); + + it('declines simulator runs', async () => { + await expect( + makeProvider().prepare({ destination: 'simulator', projectRoot }) + ).resolves.toBeNull(); + expect(profileLookup).not.toHaveBeenCalled(); + }); + + it('declines before reading any profile when the package ships no artifact', async () => { + await expect(makeProvider().prepare(deviceContext())).resolves.toBeNull(); + // Profile discovery decodes every installed .mobileprovision, so a run + // that has no usable artifact must not trigger it. + expect(profileLookup).not.toHaveBeenCalled(); + }); + + it('declines when no signing configuration exists', async () => { + writeDeviceIndex(); + + await expect( + makeProvider().prepare({ destination: 'device', projectRoot }) + ).resolves.toBeNull(); + expect(profileLookup).not.toHaveBeenCalled(); + }); + + it('declines when no profile can sign the agent', async () => { + writeDeviceIndex(); + profileLookup = vi.fn(async () => null); + + await expect(makeProvider().prepare(deviceContext())).resolves.toBeNull(); + expect(codesignCalls()).toHaveLength(0); + }); + + it('declines on an Xcode older than the supported minimum', async () => { + writeDeviceIndex(); + const baseImplementation = mocks.spawn.getMockImplementation(); + mocks.spawn.mockImplementation((file: string, args?: string[]) => + file === 'xcodebuild' && args?.join(' ') === '-version' + ? Promise.resolve({ stdout: 'Xcode 16.4\nBuild version 16F6' }) + : baseImplementation?.(file, args) + ); + + await expect(makeProvider().prepare(deviceContext())).resolves.toBeNull(); + expect(profileLookup).not.toHaveBeenCalled(); + expect(codesignCalls()).toHaveLength(0); + }); + + it('signs a copy in the project cache and leaves the packaged artifact unsigned', async () => { + const { directoryName, xctestrunName } = writeDeviceIndex(); + + const artifacts = await makeProvider().prepare(deviceContext()); + + expect(artifacts?.source).toBe('prebuilt'); + if (artifacts?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + + const unpackedRoot = artifacts.launch.derivedDataPath; + + expect(path.basename(unpackedRoot)).toMatch( + /^xctest-agent-prebuilt-device-abc123-/ + ); + expect(artifacts.launch.xctestrunPath).toBe( + path.join(unpackedRoot, 'Build', 'Products', xctestrunName) + ); + + const signed = codesignCalls(); + + expect(signed).toHaveLength(3); + // Signing runs on a staged copy that is renamed into place afterwards, so + // compare paths below each copy's products directory. + expect( + signed.map( + (call) => + signedTarget(call).split( + `${path.sep}Build${path.sep}Products${path.sep}` + )[1] + ) + ).toEqual([ + path.join( + 'Debug-iphoneos', + 'HarnessXCTestAgentUITests-Runner.app', + 'PlugIns', + 'HarnessXCTestAgentUITests.xctest' + ), + path.join('Debug-iphoneos', 'HarnessXCTestAgentUITests-Runner.app'), + path.join('Debug-iphoneos', 'HarnessXCTestAgent.app'), + ]); + expect( + fs + .readdirSync(path.dirname(unpackedRoot)) + .filter((entry) => entry.includes('.staging-')) + ).toEqual([]); + expect(directoryName).toBe('device-abc123'); + + // The packaged artifact is shared by every project on the machine, so it + // must never carry a signature for one consumer's identity. + const packagedProducts = path.join( + packageRoot, + 'xctest-agent-prebuilds', + directoryName, + 'Build', + 'Products' + ); + + for (const bundle of [ + 'HarnessXCTestAgentUITests-Runner.app', + 'HarnessXCTestAgent.app', + ]) { + expect( + fs.existsSync( + path.join( + packagedProducts, + 'Debug-iphoneos', + bundle, + 'embedded.mobileprovision' + ) + ) + ).toBe(false); + } + }); + + it('asks the profile lookup about the target device', async () => { + writeDeviceIndex(); + + await makeProvider().prepare(deviceContext()); + + expect(profileLookup).toHaveBeenCalledWith( + expect.objectContaining({ deviceId: device, teamId }) + ); + }); + + it('signs under the configured bundle id prefix', async () => { + writeDeviceIndex(); + + const artifacts = await makeProvider().prepare({ + ...deviceContext(), + signing: { + bundleIdPrefix: 'com.example.consumer', + signingIdentity: identity, + teamId, + }, + }); + + if (artifacts?.launch.kind !== 'xctestrun') { + throw new Error('Expected an xctestrun launch input'); + } + + expect( + readPlist( + path.join( + path.dirname(artifacts.launch.xctestrunPath), + 'Debug-iphoneos', + 'HarnessXCTestAgent.app', + 'Info.plist' + ), + 'CFBundleIdentifier' + ) + ).toBe('com.example.consumer.HarnessXCTestAgent'); + // A configured prefix decides the bundle ids outright, so the lookup is + // asked about the remapped names and never about the shipped ones. + expect(profileLookup).toHaveBeenCalledTimes(1); + expect(profileLookup).toHaveBeenCalledWith( + expect.objectContaining({ bundleIdPrefix: 'com.example.consumer' }) + ); + }); + + it('declines when the configured prefix matches no profile, even if the shipped ids would', async () => { + writeDeviceIndex(); + profileLookup = vi.fn(async (options: { bundleIdPrefix?: string }) => + options.bundleIdPrefix ? null : profile + ); + + await expect( + makeProvider().prepare({ + ...deviceContext(), + signing: { + bundleIdPrefix: 'com.example.consumer', + signingIdentity: identity, + teamId, + }, + }) + ).resolves.toBeNull(); + expect(codesignCalls()).toHaveLength(0); + }); + + it('signs once and reuses the signed copy', async () => { + writeDeviceIndex(); + const provider = makeProvider(); + + await provider.prepare(deviceContext()); + expect(codesignCalls()).toHaveLength(3); + + await provider.prepare(deviceContext()); + expect(codesignCalls()).toHaveLength(3); + }); + + it('falls back to building when signing fails, leaving nothing half-signed', async () => { + const { directoryName } = writeDeviceIndex(); + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild') { + return Promise.resolve({ + stdout: + args?.join(' ') === '-version' + ? `Xcode ${xcodeVersion}\nBuild version ${xcodeBuild}` + : deviceSdkVersion, + }); + } + + if (file === 'codesign' && args?.[0] === '--verify') { + return Promise.reject( + new Error( + 'resource fork, Finder information, or similar detritus not allowed' + ) + ); + } + + return Promise.resolve({ stdout: '' }); + }); + + await expect(makeProvider().prepare(deviceContext())).resolves.toBeNull(); + + // Unpacked copies are named `xctest-agent-prebuilt--`, + // so filtering on the artifact's own directory name would match nothing and + // the assertion would pass even if the half-signed copy were left behind. + const cacheRoot = path.join(projectRoot, '.harness', 'cache'); + const leftovers = fs + .readdirSync(cacheRoot) + .filter((entry) => + entry.startsWith(`xctest-agent-prebuilt-${directoryName}`) + ); + + expect(leftovers).toEqual([]); + }); + + it('keys the cache by profile so switching identities re-signs', async () => { + writeDeviceIndex(); + const otherProfile = { ...profile, uuid: 'profile-uuid-2' }; + profileLookup = vi.fn(async () => profile); + + const first = await makeProvider().prepare(deviceContext()); + expect(codesignCalls()).toHaveLength(3); + + profileLookup = vi.fn(async () => otherProfile); + const second = await makeProvider().prepare(deviceContext()); + + expect(codesignCalls()).toHaveLength(6); + if ( + first?.launch.kind !== 'xctestrun' || + second?.launch.kind !== 'xctestrun' + ) { + throw new Error('Expected xctestrun launch inputs'); + } + expect(second.launch.derivedDataPath).not.toBe( + first.launch.derivedDataPath + ); + }); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index 0b6605dc..fce60246 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createHash } from 'node:crypto'; import { PassThrough } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -77,6 +76,7 @@ import { buildXCTestAgent, createXCTestAgentController, } from '../xctest-agent.js'; +import { getProjectInputsHash } from '../xctest-agent-build.js'; import { createDeviceXCTestAgentTransport } from '../xctest-agent-transport-device.js'; import { createSimulatorXCTestAgentTransport } from '../xctest-agent-transport-simulator.js'; @@ -96,6 +96,7 @@ const xcodeVersion = 'Xcode 26.0\nBuild version 17A123'; const originalExternalXCTestRunFile = process.env.HARNESS_IOS_XCTESTRUN_FILE; const originalExternalDerivedDataPath = process.env.HARNESS_IOS_XCTEST_DERIVED_DATA_PATH; +const originalSkipPrebuilts = process.env.HARNESS_SKIP_XCTEST_PREBUILDS; const createLongRunningSubprocess = (options?: { ignoreSignal?: NodeJS.Signals; @@ -159,6 +160,11 @@ const createLongRunningSubprocess = (options?: { describe('xctest-agent orchestration', () => { beforeEach(() => { vi.clearAllMocks(); + // These tests cover the controller and the build provider. Shipped artifacts + // are exercised by their own suites, and leaving them reachable here would + // make the outcome depend on whether this checkout happens to have packed + // any — and would decode the developer's real provisioning profiles. + process.env.HARNESS_SKIP_XCTEST_PREBUILDS = '1'; mocks.health.mockResolvedValue({ permissions: { autoAcceptPermissions: false, @@ -280,6 +286,7 @@ describe('xctest-agent orchestration', () => { 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH', originalExternalDerivedDataPath ); + restoreEnvVar('HARNESS_SKIP_XCTEST_PREBUILDS', originalSkipPrebuilts); rmBuildRoot(); process.chdir(originalCwd); fs.rmSync(tempProjectRoot, { recursive: true, force: true }); @@ -319,6 +326,24 @@ describe('xctest-agent orchestration', () => { ).toBe(true); }); + it('builds without coverage, indexing, previews, or debug info', async () => { + await buildXCTestAgent({ destination: 'simulator' }); + + const buildArgs = mocks.spawn.mock.calls.find( + ([file, args]) => file === 'xcodebuild' && args?.[0] === 'build-for-testing' + )?.[1] as string[]; + + expect(buildArgs).toEqual( + expect.arrayContaining([ + 'COMPILER_INDEX_STORE_ENABLE=NO', + 'ENABLE_DEBUG_DYLIB=NO', + 'ENABLE_PREVIEWS=NO', + 'GCC_GENERATE_DEBUGGING_SYMBOLS=NO', + ]) + ); + expect(buildArgs[buildArgs.indexOf('-enableCodeCoverage') + 1]).toBe('NO'); + }); + it('builds standalone simulator agent artifacts with the generic simulator destination', async () => { const result = await buildXCTestAgent({ destination: 'simulator', @@ -892,28 +917,7 @@ const restoreEnvVar = (name: string, value: string | undefined) => { process.env[name] = value; }; -const getCurrentInputsHash = (): string => { - const hash = createHash('sha256'); - - for (const filePath of getInputFiles(projectRoot)) { - hash.update(path.relative(projectRoot, filePath)); - hash.update('\0'); - hash.update(fs.readFileSync(filePath)); - hash.update('\0'); - } - - const sourceFilePath = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'xctest-agent.ts' - ); - hash.update(path.basename(sourceFilePath)); - hash.update('\0'); - hash.update(fs.readFileSync(sourceFilePath)); - hash.update('\0'); - - return hash.digest('hex'); -}; +const getCurrentInputsHash = (): string => getProjectInputsHash(); const writeSimulatorCacheDirectory = (options: { buildInputsHash: string; @@ -948,25 +952,3 @@ const writeSimulatorCacheDirectory = (options: { }) ); }; - -const getInputFiles = (root: string): string[] => { - const entries = fs.readdirSync(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - if (entry.name === 'build' || entry.name === '.gitignore') { - continue; - } - - const entryPath = path.join(root, entry.name); - - if (entry.isDirectory()) { - files.push(...getInputFiles(entryPath)); - continue; - } - - files.push(entryPath); - } - - return files.sort(); -}; diff --git a/packages/platform-ios/src/cli.ts b/packages/platform-ios/src/cli.ts index c7b5ca97..e47935ca 100644 --- a/packages/platform-ios/src/cli.ts +++ b/packages/platform-ios/src/cli.ts @@ -6,7 +6,7 @@ const cli = { { name: 'xctest', run: async (args, context) => { - const { buildXCTestAgent } = await import('./xctest-agent.js'); + const { buildXCTestAgent } = await import('./xctest-agent-build.js'); await runXCTestCommand({ args, diff --git a/packages/platform-ios/src/config.ts b/packages/platform-ios/src/config.ts index 56490f86..0b284bf0 100644 --- a/packages/platform-ios/src/config.ts +++ b/packages/platform-ios/src/config.ts @@ -15,6 +15,22 @@ export const ApplePhysicalDeviceCodeSignSchema = z.object({ teamId: z.string().min(1, 'Team ID is required'), signingIdentity: z.string().optional(), provisioningProfile: z.string().optional(), + /** + * Reverse-DNS prefix the shipped artifact's bundle ids are rewritten to before + * signing. Needed when the account's profile covers only its own identifiers + * and cannot cover `com.callstackincubator.*`; the profile must cover the + * remapped ids, e.g. `com.example.app.HarnessXCTestAgent`. Applies to the + * shipped artifact only: a locally built agent keeps the shipped ids, because + * `xcodebuild` cannot override one target's id without changing the other's. + */ + bundleIdPrefix: z + .string() + .min(1, 'Bundle ID prefix is required') + .regex( + /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/, + 'Bundle ID prefix must be a reverse-DNS prefix such as "com.example.app"' + ) + .optional(), }); export const ApplePhysicalDeviceSchema = z.object({ diff --git a/packages/platform-ios/src/index.ts b/packages/platform-ios/src/index.ts index 6f35f792..17dcf600 100644 --- a/packages/platform-ios/src/index.ts +++ b/packages/platform-ios/src/index.ts @@ -6,10 +6,12 @@ export { export type { ApplePlatformConfig } from './config.js'; export { HarnessAppPathError } from './errors.js'; export { getRunTargets } from './targets.js'; -export { buildXCTestAgent } from './xctest-agent.js'; +export { buildXCTestAgent } from './xctest-agent-build.js'; export type { BuildXCTestAgentOptions, BuildXCTestAgentResult, +} from './xctest-agent-build.js'; +export type { XCTestAgentBuildDestination, XCTestAgentBuildSigning, -} from './xctest-agent.js'; +} from './xctest-agent-artifacts.js'; diff --git a/packages/platform-ios/src/xctest-agent-artifacts.ts b/packages/platform-ios/src/xctest-agent-artifacts.ts new file mode 100644 index 00000000..64f379d9 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-artifacts.ts @@ -0,0 +1,104 @@ +import { logger } from '@react-native-harness/tools'; + +const xctestAgentLogger = logger.child('ios-xctest-agent'); + +export type XCTestAgentBuildDestination = 'simulator' | 'device'; + +export type XCTestAgentBuildSigning = { + /** Rewrites the agent's bundle ids; see `ApplePhysicalDeviceCodeSign`. */ + bundleIdPrefix?: string; + provisioningProfile?: string; + signingIdentity?: string; + teamId?: string; +}; + +/** + * How a prepared artifact set reached the machine it runs from. + * + * - `external`: supplied through `HARNESS_IOS_XCTESTRUN_FILE`. + * - `prebuilt`: shipped with the package and unpacked from it. + * - `build`: produced locally with `xcodebuild build-for-testing`. + */ +export type XCTestAgentArtifactSource = 'external' | 'prebuilt' | 'build'; + +/** + * Everything `xcodebuild test-without-building` needs in order to launch the + * agent. The provider decides which form the launch input takes, so the + * controller never has to know whether artifacts came from a build or a + * download. + */ +export type XCTestAgentLaunchInput = + | { + derivedDataPath: string; + kind: 'project'; + projectFilePath: string; + scheme: string; + } + | { + derivedDataPath: string; + kind: 'xctestrun'; + xctestrunPath: string; + }; + +export type XCTestAgentArtifacts = { + launch: XCTestAgentLaunchInput; + source: XCTestAgentArtifactSource; +}; + +export type XCTestAgentProviderContext = { + /** UDID of the target device. Device providers need it to check a profile covers it. */ + deviceId?: string; + destination: XCTestAgentBuildDestination; + /** Consumer project root, used as the anchor for `.harness` paths. */ + projectRoot: string; + /** Signing request for device runs. Providers that cannot honour it must decline. */ + signing?: XCTestAgentBuildSigning; + signal?: AbortSignal; +}; + +/** + * A way of obtaining a runnable XCTest agent. Providers are consulted in + * order and the first one that returns artifacts wins. Returning `null` means + * "not applicable here, try the next provider"; throwing means the run should + * fail rather than silently fall back. + */ +export type XCTestAgentProvider = { + id: string; + prepare: ( + context: XCTestAgentProviderContext + ) => XCTestAgentArtifacts | null | Promise; +}; + +export const resolveXCTestAgentArtifacts = async ( + providers: XCTestAgentProvider[], + context: XCTestAgentProviderContext +): Promise => { + const attempted: string[] = []; + + for (const provider of providers) { + xctestAgentLogger.debug( + 'trying XCTest agent artifact provider %s for %s', + provider.id, + context.destination + ); + + const artifacts = await provider.prepare(context); + + if (!artifacts) { + attempted.push(provider.id); + continue; + } + + xctestAgentLogger.debug( + 'XCTest agent artifacts for %s provided by %s', + context.destination, + provider.id + ); + + return artifacts; + } + + throw new Error( + `Unable to prepare XCTest agent artifacts for ${context.destination}: no provider supplied artifacts (tried ${attempted.join(', ') || 'none'}).` + ); +}; diff --git a/packages/platform-ios/src/xctest-agent-build.ts b/packages/platform-ios/src/xctest-agent-build.ts new file mode 100644 index 00000000..ca3a859b --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-build.ts @@ -0,0 +1,839 @@ +import { + getHarnessCacheRootPath, + logger, + spawn, +} from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + XCTestAgentBuildDestination, + XCTestAgentBuildSigning, + XCTestAgentProvider, +} from './xctest-agent-artifacts.js'; + +const xctestAgentLogger = logger.child('ios-xctest-agent'); + +export const XCTEST_AGENT_PROJECT_NAME = 'HarnessXCTestAgent'; +export const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent'; + +const HARNESS_DIRNAME = '.harness'; +const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; +const XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT = 'xctest-agent-simulator'; +const XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION = 1; + +export type BuildXCTestAgentOptions = { + destination: XCTestAgentBuildDestination; + signing?: XCTestAgentBuildSigning; + projectRoot?: string; + signal?: AbortSignal; +}; + +export type BuildXCTestAgentResult = { + destination: XCTestAgentBuildDestination; + derivedDataPath: string; + reused: boolean; + xctestrunPath?: string; +}; + +type XCTestAgentBuildManifest = { + buildInputsHash: string; + destinationKind: XCTestAgentBuildDestination; + signing?: XCTestAgentBuildSigning; +}; + +type SimulatorXCTestAgentCacheManifest = { + artifactName: typeof XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT; + buildInputsHash: string; + destinationKind: 'simulator'; + hostArchitecture: string; + schemaVersion: typeof XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION; + simulatorSdkVersion: string; + xcodeVersion: string; + xctestrunRelativePath: string; +}; + +type SimulatorXCTestAgentCacheContext = Omit< + SimulatorXCTestAgentCacheManifest, + 'artifactName' | 'destinationKind' | 'schemaVersion' | 'xctestrunRelativePath' +>; + +export const getXCTestAgentProjectRoot = (): string => { + return fileURLToPath(new URL('../xctest-agent', import.meta.url)); +}; + +export const getXCTestAgentProjectFilePath = (): string => { + return path.join( + getXCTestAgentProjectRoot(), + `${XCTEST_AGENT_PROJECT_NAME}.xcodeproj` + ); +}; + +const assertXCTestAgentProjectExists = () => { + const projectFilePath = getXCTestAgentProjectFilePath(); + + if (fs.existsSync(projectFilePath)) { + return; + } + + throw new Error( + `Missing checked-in XCTest agent project at ${projectFilePath}. Include the checked-in project in the package artifact.` + ); +}; + +const getXCTestAgentBuildRoot = (projectRoot = process.cwd()): string => { + return path.join(projectRoot, HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); +}; + +const getXCTestAgentCacheRoot = (projectRoot = process.cwd()): string => { + return getHarnessCacheRootPath(projectRoot); +}; + +export const getXCTestAgentDerivedDataPath = ( + destination: XCTestAgentBuildDestination, + projectRoot = process.cwd() +): string => { + return path.join(getXCTestAgentBuildRoot(projectRoot), destination); +}; + +const getXCTestAgentBuildDestination = ( + destination: XCTestAgentBuildDestination +): string => { + if (destination === 'simulator') { + return 'generic/platform=iOS Simulator'; + } + + return 'generic/platform=iOS'; +}; + +const getXCTestAgentBuildSigningArgs = ( + destination: XCTestAgentBuildDestination, + signing?: XCTestAgentBuildSigning +): string[] => { + if (destination === 'simulator') { + return [ + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', + 'DEVELOPMENT_TEAM=', + ]; + } + + if (!signing) { + return ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']; + } + + const args: string[] = []; + + if (signing.teamId) { + args.push('CODE_SIGN_STYLE=Automatic'); + args.push(`DEVELOPMENT_TEAM=${signing.teamId}`); + + if (signing.signingIdentity) { + args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); + } else { + args.push('CODE_SIGN_IDENTITY=Apple Development'); + } + } else if (signing.signingIdentity) { + args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); + } + + if (signing.provisioningProfile) { + args.push(`PROVISIONING_PROFILE_SPECIFIER=${signing.provisioningProfile}`); + } + + return args; +}; + +export const getXCTestAgentBuildProductsPath = ( + derivedDataPath: string +): string => path.join(derivedDataPath, 'Build', 'Products'); + +const getXCTestAgentBuildManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'build-manifest.json'); + +const getXCTestAgentCacheManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'cache.json'); + +const readBuildManifest = ( + derivedDataPath: string +): XCTestAgentBuildManifest | null => { + const manifestPath = getXCTestAgentBuildManifestPath(derivedDataPath); + + if (!fs.existsSync(manifestPath)) { + return null; + } + + return JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as XCTestAgentBuildManifest; +}; + +const writeBuildManifest = ( + derivedDataPath: string, + manifest: XCTestAgentBuildManifest +) => { + fs.mkdirSync(derivedDataPath, { recursive: true }); + fs.writeFileSync( + getXCTestAgentBuildManifestPath(derivedDataPath), + JSON.stringify(manifest, null, 2) + ); +}; + +// Local and editor state that never ships in the package. Hashing it would give +// a checkout that opened the project in Xcode a different digest from the +// installed package, so packed artifacts would never be recognised. +const IGNORED_PROJECT_ENTRIES = new Set([ + '.DS_Store', + '.gitignore', + 'build', + 'xcuserdata', +]); + +const getProjectInputFilePaths = (root: string): string[] => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (IGNORED_PROJECT_ENTRIES.has(entry.name)) { + continue; + } + + const entryPath = path.join(root, entry.name); + + if (entry.isDirectory()) { + files.push(...getProjectInputFilePaths(entryPath)); + continue; + } + + files.push(entryPath); + } + + return files.sort(); +}; + +const getBuildInputsSourceFilePath = (): string => { + return fileURLToPath(import.meta.url); +}; + +/** + * Fingerprint of the checked-in agent project sources. + * + * These files ship verbatim inside the package, so the digest is the same in + * the repository and in an installed package. That is what lets artifacts + * packed at release time be recognised by a consumer. + */ +export const getAgentProjectSourcesHash = (): string => { + const projectRoot = getXCTestAgentProjectRoot(); + const hash = createHash('sha256'); + + for (const filePath of getProjectInputFilePaths(projectRoot)) { + hash.update(path.relative(projectRoot, filePath)); + hash.update('\0'); + hash.update(fs.readFileSync(filePath)); + hash.update('\0'); + } + + return hash.digest('hex'); +}; + +/** + * Fingerprint used for local build reuse: the agent sources plus the build + * logic that turns them into artifacts. Includes this module's own source, so + * a change to the build arguments invalidates a previously cached build. + */ +export const getProjectInputsHash = (): string => { + const hash = createHash('sha256'); + hash.update(getAgentProjectSourcesHash()); + hash.update('\0'); + + const sourceFilePath = getBuildInputsSourceFilePath(); + hash.update(path.basename(sourceFilePath)); + hash.update('\0'); + hash.update(fs.readFileSync(sourceFilePath)); + hash.update('\0'); + + return hash.digest('hex'); +}; + +const getXCTestRunRelativePath = (derivedDataPath: string): string | null => { + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + if (!fs.existsSync(buildProductsPath)) { + return null; + } + + const entries = fs.readdirSync(buildProductsPath, { recursive: true }); + const xctestrunEntry = entries.find( + (entry) => typeof entry === 'string' && entry.endsWith('.xctestrun') + ); + + return typeof xctestrunEntry === 'string' ? xctestrunEntry : null; +}; + +const readSimulatorBuildManifest = ( + derivedDataPath: string +): SimulatorXCTestAgentCacheManifest | null => { + const manifestPath = getXCTestAgentCacheManifestPath(derivedDataPath); + + if (!fs.existsSync(manifestPath)) { + return null; + } + + const manifest = JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as Partial; + + if ( + manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || + manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || + manifest.destinationKind !== 'simulator' || + typeof manifest.buildInputsHash !== 'string' || + typeof manifest.hostArchitecture !== 'string' || + typeof manifest.simulatorSdkVersion !== 'string' || + typeof manifest.xcodeVersion !== 'string' || + typeof manifest.xctestrunRelativePath !== 'string' + ) { + return null; + } + + return manifest as SimulatorXCTestAgentCacheManifest; +}; + +const writeSimulatorBuildManifest = ( + derivedDataPath: string, + manifest: SimulatorXCTestAgentCacheManifest +) => { + fs.mkdirSync(derivedDataPath, { recursive: true }); + fs.writeFileSync( + getXCTestAgentCacheManifestPath(derivedDataPath), + JSON.stringify(manifest, null, 2) + ); +}; + +const getSimulatorCacheDirectoryName = ( + context: SimulatorXCTestAgentCacheContext +): string => { + const hash = createHash('sha256'); + hash.update(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); + hash.update('\0'); + hash.update(String(XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION)); + hash.update('\0'); + hash.update(context.buildInputsHash); + hash.update('\0'); + hash.update(context.hostArchitecture); + hash.update('\0'); + hash.update(context.simulatorSdkVersion); + hash.update('\0'); + hash.update(context.xcodeVersion); + + return `${XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT}-${hash + .digest('hex') + .slice(0, 12)}`; +}; + +const getSimulatorCacheDerivedDataPath = ( + context: SimulatorXCTestAgentCacheContext, + projectRoot = process.cwd() +): string => { + return path.join( + getXCTestAgentCacheRoot(projectRoot), + getSimulatorCacheDirectoryName(context) + ); +}; + +const getHarnessCacheDirectories = (projectRoot = process.cwd()): string[] => { + const cacheRoot = getXCTestAgentCacheRoot(projectRoot); + + if (!fs.existsSync(cacheRoot)) { + return []; + } + + return fs + .readdirSync(cacheRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(cacheRoot, entry.name)) + .sort(); +}; + +const isCompatibleSimulatorBuildManifest = ( + manifest: SimulatorXCTestAgentCacheManifest, + context: SimulatorXCTestAgentCacheContext +): boolean => { + return ( + manifest.buildInputsHash === context.buildInputsHash && + manifest.hostArchitecture === context.hostArchitecture && + manifest.simulatorSdkVersion === context.simulatorSdkVersion && + manifest.xcodeVersion === context.xcodeVersion + ); +}; + +const findReusableSimulatorBuildArtifacts = ( + context: SimulatorXCTestAgentCacheContext, + projectRoot = process.cwd() +): string | null => { + for (const derivedDataPath of getHarnessCacheDirectories(projectRoot)) { + const manifest = readSimulatorBuildManifest(derivedDataPath); + + if (!manifest || !isCompatibleSimulatorBuildManifest(manifest, context)) { + continue; + } + + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + if ( + fs.existsSync(buildProductsPath) && + fs.existsSync( + path.join(buildProductsPath, manifest.xctestrunRelativePath) + ) + ) { + return derivedDataPath; + } + } + + return null; +}; + +const getCurrentXcodeVersion = async ( + signal?: AbortSignal +): Promise => { + const { stdout } = await spawn('xcodebuild', ['-version'], { signal }); + return stdout.trim(); +}; + +const getCurrentSimulatorSdkVersion = async ( + signal?: AbortSignal +): Promise => { + const { stdout } = await spawn( + 'xcodebuild', + ['-version', '-sdk', 'iphonesimulator', 'SDKVersion'], + { signal } + ); + return stdout.trim(); +}; + +export type XCTestAgentToolchain = { + sdkVersion: string; + xcodeBuild: string; + xcodeVersion: string; +}; + +const SDK_PLATFORM_NAMES: Record = { + device: 'iPhoneOS', + simulator: 'iPhoneSimulator', +}; + +/** + * Reads the toolchain from the files `xcodebuild -version` itself reports from: + * the selected Xcode's `version.plist` and the SDK's `SDKSettings.json`. + * + * Every iOS run resolves the toolchain before it can use a shipped artifact, + * and the two `xcodebuild` invocations cost about a second together, while + * these reads take a few milliseconds. `xcode-select -p` honours + * `DEVELOPER_DIR`, so the same Xcode is selected either way. + */ +const readXCTestAgentToolchain = async ( + destination: XCTestAgentBuildDestination, + signal?: AbortSignal +): Promise => { + const { stdout } = await spawn('xcode-select', ['-p'], { signal }); + const developerDir = stdout.trim(); + const { stdout: versionJson } = await spawn( + 'plutil', + [ + '-convert', + 'json', + '-o', + '-', + path.join(developerDir, '..', 'version.plist'), + ], + { signal } + ); + const version = JSON.parse(versionJson) as { + CFBundleShortVersionString?: string; + ProductBuildVersion?: string; + }; + const platform = SDK_PLATFORM_NAMES[destination]; + const sdkSettings = JSON.parse( + fs.readFileSync( + path.join( + developerDir, + 'Platforms', + `${platform}.platform`, + 'Developer', + 'SDKs', + `${platform}.sdk`, + 'SDKSettings.json' + ), + 'utf8' + ) + ) as { Version?: string }; + + if ( + !version.CFBundleShortVersionString || + !version.ProductBuildVersion || + !sdkSettings.Version + ) { + throw new Error(`Incomplete Xcode version information in ${developerDir}`); + } + + return { + sdkVersion: sdkSettings.Version, + xcodeBuild: version.ProductBuildVersion, + xcodeVersion: version.CFBundleShortVersionString, + }; +}; + +/** + * Identifies the selected Xcode toolchain: recorded with packed artifacts, and + * checked against the minimum a shipped artifact supports before it is used. + */ +export const resolveXCTestAgentToolchain = async ( + destination: XCTestAgentBuildDestination, + signal?: AbortSignal +): Promise => { + try { + return await readXCTestAgentToolchain(destination, signal); + } catch (error) { + signal?.throwIfAborted(); + xctestAgentLogger.debug( + 'reading the Xcode toolchain from disk failed, asking xcodebuild: %s', + error instanceof Error ? error.message : String(error) + ); + } + + const [versionOutput, sdkVersion] = await Promise.all([ + spawn('xcodebuild', ['-version'], { signal }).then(({ stdout }) => + stdout.trim() + ), + spawn( + 'xcodebuild', + [ + '-version', + '-sdk', + destination === 'simulator' ? 'iphonesimulator' : 'iphoneos', + 'SDKVersion', + ], + { signal } + ).then(({ stdout }) => stdout.trim()), + ]); + + return { + sdkVersion, + xcodeBuild: /Build version\s+(\S+)/.exec(versionOutput)?.[1] ?? '', + xcodeVersion: /Xcode\s+(\S+)/.exec(versionOutput)?.[1] ?? versionOutput, + }; +}; + +const getSimulatorCacheContext = async ( + buildInputsHash: string, + signal?: AbortSignal +): Promise => { + const [xcodeVersion, simulatorSdkVersion] = await Promise.all([ + getCurrentXcodeVersion(signal), + getCurrentSimulatorSdkVersion(signal), + ]); + + return { + buildInputsHash, + hostArchitecture: process.arch, + simulatorSdkVersion, + xcodeVersion, + }; +}; + +const shouldReuseBuildArtifacts = (options: { + buildInputsHash: string; + derivedDataPath: string; + destination: XCTestAgentBuildDestination; + signing?: XCTestAgentBuildSigning; +}): boolean => { + if (options.destination === 'simulator') { + throw new Error( + 'Simulator build reuse must be validated with cache compatibility metadata' + ); + } + + const manifest = readBuildManifest(options.derivedDataPath); + + if (!manifest) { + return false; + } + + if ( + manifest.buildInputsHash !== options.buildInputsHash || + manifest.destinationKind !== options.destination + ) { + return false; + } + + if ( + manifest.signing?.teamId !== options.signing?.teamId || + manifest.signing?.signingIdentity !== options.signing?.signingIdentity || + manifest.signing?.provisioningProfile !== + options.signing?.provisioningProfile + ) { + return false; + } + + const buildProductsPath = getXCTestAgentBuildProductsPath( + options.derivedDataPath + ); + + return fs.existsSync(buildProductsPath); +}; + +/** + * Build settings that only slow the build down or grow its products. + * + * The agent is never debugged, indexed, previewed, or measured for coverage, + * so these cut a clean build by about a third. They also keep the products + * free of the build machine's paths: coverage mapping, debug info, and the + * `.xctestrun` source map all embed absolute paths, which packed artifacts must + * not carry. + */ +const XCTEST_AGENT_BUILD_FLAGS = [ + '-enableCodeCoverage', + 'NO', + 'COMPILER_INDEX_STORE_ENABLE=NO', + 'ENABLE_DEBUG_DYLIB=NO', + 'ENABLE_PREVIEWS=NO', + 'GCC_GENERATE_DEBUGGING_SYMBOLS=NO', +]; + +/** + * Runs `xcodebuild build-for-testing` into `derivedDataPath` and returns the + * generated `.xctestrun`, relative to the build products directory. + */ +export const runXCTestAgentBuild = async (options: { + derivedDataPath: string; + destination: XCTestAgentBuildDestination; + signal?: AbortSignal; + signing?: XCTestAgentBuildSigning; +}): Promise => { + assertXCTestAgentProjectExists(); + fs.mkdirSync(options.derivedDataPath, { recursive: true }); + + const provisioningArgs = + options.destination === 'device' && options.signing?.teamId + ? ['-allowProvisioningUpdates'] + : []; + + await spawn( + 'xcodebuild', + [ + 'build-for-testing', + '-project', + getXCTestAgentProjectFilePath(), + '-scheme', + XCTEST_AGENT_SCHEME_NAME, + '-destination', + getXCTestAgentBuildDestination(options.destination), + '-derivedDataPath', + options.derivedDataPath, + ...XCTEST_AGENT_BUILD_FLAGS, + ...provisioningArgs, + ...getXCTestAgentBuildSigningArgs(options.destination, options.signing), + ], + { + cwd: getXCTestAgentProjectRoot(), + signal: options.signal, + } + ); + + const xctestrunRelativePath = getXCTestRunRelativePath( + options.derivedDataPath + ); + + if (!xctestrunRelativePath) { + throw new Error( + `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( + options.derivedDataPath + )}` + ); + } + + return xctestrunRelativePath; +}; + +const getXCTestRunPath = (derivedDataPath: string): string | undefined => { + const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); + + if (!xctestrunRelativePath) { + return undefined; + } + + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + return path.join(buildProductsPath, xctestrunRelativePath); +}; + +export const buildXCTestAgent = async ( + options: BuildXCTestAgentOptions +): Promise => { + const projectRoot = options.projectRoot ?? process.cwd(); + const buildInputsHash = getProjectInputsHash(); + let derivedDataPath = getXCTestAgentDerivedDataPath( + options.destination, + projectRoot + ); + let simulatorCacheContext: SimulatorXCTestAgentCacheContext | undefined; + + xctestAgentLogger.debug( + 'verifying checked-in XCTest agent project for %s', + options.destination + ); + xctestAgentLogger.info( + 'Using checked-in XCTest agent project for %s target', + options.destination + ); + assertXCTestAgentProjectExists(); + + if (options.destination === 'simulator') { + simulatorCacheContext = await getSimulatorCacheContext( + buildInputsHash, + options.signal + ); + const reusableDerivedDataPath = findReusableSimulatorBuildArtifacts( + simulatorCacheContext, + projectRoot + ); + + if (reusableDerivedDataPath) { + xctestAgentLogger.info( + 'Reusing cached XCTest agent build for %s target', + options.destination + ); + xctestAgentLogger.debug( + 'reusing cached XCTest agent build for %s', + options.destination + ); + + return { + destination: options.destination, + derivedDataPath: reusableDerivedDataPath, + reused: true, + xctestrunPath: getXCTestRunPath(reusableDerivedDataPath), + }; + } + + derivedDataPath = getSimulatorCacheDerivedDataPath( + simulatorCacheContext, + projectRoot + ); + } else { + const canReuseBuild = shouldReuseBuildArtifacts({ + buildInputsHash, + derivedDataPath, + destination: options.destination, + signing: options.signing, + }); + + if (canReuseBuild) { + xctestAgentLogger.info( + 'Reusing cached XCTest agent build for %s target', + options.destination + ); + xctestAgentLogger.debug( + 'reusing cached XCTest agent build for %s', + options.destination + ); + + return { + destination: options.destination, + derivedDataPath, + reused: true, + xctestrunPath: getXCTestRunPath(derivedDataPath), + }; + } + } + + xctestAgentLogger.debug('building XCTest agent for %s', options.destination); + xctestAgentLogger.info( + 'Building XCTest agent for %s target', + options.destination + ); + + const xctestrunRelativePath = await runXCTestAgentBuild({ + derivedDataPath, + destination: options.destination, + signal: options.signal, + signing: options.signing, + }); + + if (options.destination === 'simulator') { + if (!simulatorCacheContext) { + throw new Error('Missing simulator cache context for XCTest agent build'); + } + + writeSimulatorBuildManifest(derivedDataPath, { + artifactName: XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT, + destinationKind: 'simulator', + schemaVersion: XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION, + xctestrunRelativePath, + ...simulatorCacheContext, + }); + } else { + writeBuildManifest(derivedDataPath, { + buildInputsHash, + destinationKind: options.destination, + signing: options.signing, + }); + } + + xctestAgentLogger.info( + 'Built XCTest agent for %s target', + options.destination + ); + + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + return { + destination: options.destination, + derivedDataPath, + reused: false, + xctestrunPath: path.join(buildProductsPath, xctestrunRelativePath), + }; +}; + +/** + * Fallback provider: produces artifacts on the consumer's machine with + * `xcodebuild build-for-testing`, reusing a local build when its manifest + * still matches. + */ +export const createBuildXCTestAgentProvider = (): XCTestAgentProvider => ({ + id: 'build', + prepare: async (context) => { + // The remap escape hatch belongs to the prebuilt path alone. It cannot be + // applied to a build: `xcodebuild` takes `PRODUCT_BUNDLE_IDENTIFIER` as a + // global override, and the agent project builds two targets whose ids must + // stay distinct, so one override would collapse the app and the test bundle + // onto the same id and the install would be rejected as a duplicate. + if (context.destination === 'device' && context.signing?.bundleIdPrefix) { + xctestAgentLogger.warn( + 'device.codeSign.bundleIdPrefix cannot be applied to a locally built agent (it would give both agent targets the same bundle id), building com.callstackincubator.HarnessXCTestAgent as-is' + ); + } + + const result = await buildXCTestAgent({ + destination: context.destination, + signing: context.signing, + projectRoot: context.projectRoot, + signal: context.signal, + }); + + return { + // `test-without-building` resolves products inside the derived data + // folder from the project and scheme, which is how a locally built + // agent has always been launched. + launch: { + derivedDataPath: result.derivedDataPath, + kind: 'project', + projectFilePath: getXCTestAgentProjectFilePath(), + scheme: XCTEST_AGENT_SCHEME_NAME, + }, + source: 'build', + }; + }, +}); diff --git a/packages/platform-ios/src/xctest-agent-prebuilt.ts b/packages/platform-ios/src/xctest-agent-prebuilt.ts new file mode 100644 index 00000000..7f67a655 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-prebuilt.ts @@ -0,0 +1,783 @@ +import { getHarnessCacheRootPath, logger } from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { + getAgentProjectSourcesHash, + getXCTestAgentBuildProductsPath, + resolveXCTestAgentToolchain, + runXCTestAgentBuild, +} from './xctest-agent-build.js'; +import type { + XCTestAgentArtifacts, + XCTestAgentBuildDestination, + XCTestAgentProvider, +} from './xctest-agent-artifacts.js'; + +const xctestAgentLogger = logger.child('ios-xctest-agent'); + +const PREBUILT_DIRNAME = 'xctest-agent-prebuilds'; +const PREBUILT_INDEX_NAME = 'index.json'; +const PREBUILT_INDEX_SCHEMA_VERSION = 1; +const PREBUILT_CACHE_PREFIX = 'xctest-agent-prebuilt'; +const PREBUILT_CACHE_MARKER = 'prebuilt-cache.json'; +const XCTEST_PREBUILT_ROOT_ENV = 'HARNESS_IOS_XCTEST_PREBUILT_ROOT'; +const XCTEST_SKIP_PREBUILDS_ENV = 'HARNESS_SKIP_XCTEST_PREBUILDS'; + +/** + * Oldest Xcode major version a shipped artifact is used with. + * + * Artifacts do not need the exact Xcode they were packed with: the agent + * targets iOS 15, the runner gets its XCTest frameworks either embedded and + * re-signed or from the running Xcode, and artifacts packed with Xcode 26.4.1 + * and 27.0 were each verified to run under the other on simulators and + * devices. Older Xcodes are untested, and so is any major version newer than + * the one that packed the artifact; both build locally instead. + */ +const MIN_PREBUILT_XCODE_MAJOR_VERSION = 26; + +const getXcodeMajorVersion = (xcodeVersion: string): number => + Number.parseInt(xcodeVersion, 10); + +/** + * Whether an artifact may run under the local Xcode: from the oldest supported + * major version up to the major version that packed it. A newer Xcode may change + * the `.xctestrun` format or the test runner protocol, so it builds locally + * until a release packs an artifact with it. + */ +const supportsXcode = ( + entry: XCTestAgentPrebuiltEntry, + xcodeVersion: string +): boolean => { + const localMajorVersion = getXcodeMajorVersion(xcodeVersion); + + return ( + localMajorVersion >= MIN_PREBUILT_XCODE_MAJOR_VERSION && + localMajorVersion <= getXcodeMajorVersion(entry.xcodeVersion) + ); +}; + +/** Build output that never affects a test run but dominates artifact size. */ +const STRIPPED_ARTIFACT_SUFFIXES = ['.dSYM', '.swiftmodule', '.bcsymbolmap']; + +export type XCTestAgentPrebuiltEntry = { + /** Version of `@react-native-harness/platform-apple` that packed this artifact. */ + agentVersion: string; + buildInputsHash: string; + destination: XCTestAgentBuildDestination; + /** Directory name under `xctest-agent-prebuilds/`, relative to the index. */ + directoryName: string; + sdkVersion: string; + /** Path of the `.xctestrun` inside the artifact's `Build/Products`. */ + xctestrunRelativePath: string; + xcodeBuild: string; + xcodeVersion: string; +}; + +export type XCTestAgentPrebuiltIndex = { + entries: XCTestAgentPrebuiltEntry[]; + schemaVersion: typeof PREBUILT_INDEX_SCHEMA_VERSION; +}; + +export type PackXCTestAgentArtifactsOptions = { + /** + * Package version the artifacts are published under. Release tooling passes + * the version being released, because `package.json` is still bumped + * afterwards; it defaults to the version currently in `package.json`. + */ + agentVersion?: string; + destination: XCTestAgentBuildDestination; + /** Where the packed artifacts land. Defaults to the installed package. */ + packageRoot?: string; + /** Project anchoring the local build cache. Defaults to the working directory. */ + projectRoot?: string; + signal?: AbortSignal; +}; + +export type PackXCTestAgentArtifactsResult = { + artifactDirectoryPath: string; + entry: XCTestAgentPrebuiltEntry; + indexFilePath: string; +}; + +/** + * Root of the installed package, which is the directory holding the agent + * project. Anchored on the project rather than on `../`, because this module + * runs both as `src/*.ts` through the `development` export and as + * `dist/*.js`, and those two locations sit a different number of levels from + * the package root. + */ +export const getPackageRootPath = (): string => { + return path.dirname( + fileURLToPath(new URL('../xctest-agent', import.meta.url)) + ); +}; + +export const getPrebuiltRootPath = ( + packageRoot = getPackageRootPath() +): string => path.join(packageRoot, PREBUILT_DIRNAME); + +export const getPrebuiltIndexPath = ( + packageRoot = getPackageRootPath() +): string => path.join(getPrebuiltRootPath(packageRoot), PREBUILT_INDEX_NAME); + +const getPackageVersion = (): string => { + const packageJsonPath = path.join(getPackageRootPath(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + version?: string; + }; + + if (!packageJson.version) { + throw new Error(`Missing version in ${packageJsonPath}`); + } + + return packageJson.version; +}; + +/** + * Root that holds the prebuilt artifact index, and therefore the artifacts + * themselves. Defaults to the installed package; the environment override + * points Harness at artifacts packed elsewhere, which is how a repository + * rehearses the prebuilt path before a release exists. + */ +export const resolvePrebuiltPackageRoot = (): string => { + const configured = process.env[XCTEST_PREBUILT_ROOT_ENV]?.trim(); + + return configured ? path.resolve(configured) : getPackageRootPath(); +}; + +const isPrebuiltEntry = (value: unknown): value is XCTestAgentPrebuiltEntry => { + const entry = value as Partial | null; + + return ( + typeof entry?.agentVersion === 'string' && + typeof entry?.buildInputsHash === 'string' && + (entry.destination === 'simulator' || entry.destination === 'device') && + typeof entry?.directoryName === 'string' && + typeof entry?.sdkVersion === 'string' && + typeof entry?.xctestrunRelativePath === 'string' && + typeof entry?.xcodeVersion === 'string' + ); +}; + +export const readPrebuiltIndex = ( + indexPath: string +): XCTestAgentPrebuiltIndex | null => { + if (!fs.existsSync(indexPath)) { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + } catch { + return null; + } + + const index = parsed as Partial; + + if ( + index.schemaVersion !== PREBUILT_INDEX_SCHEMA_VERSION || + !Array.isArray(index.entries) || + !index.entries.every(isPrebuiltEntry) + ) { + return null; + } + + return index as XCTestAgentPrebuiltIndex; +}; + +const writePrebuiltIndex = ( + indexPath: string, + index: XCTestAgentPrebuiltIndex +) => { + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`); +}; + +// `cpSync` visits a stripped directory's children individually, so matching +// the directory name alone would recreate the tree and copy its contents. +const shouldCopyArtifactPath = (sourcePath: string): boolean => + !sourcePath + .split(path.sep) + .some((segment) => + STRIPPED_ARTIFACT_SUFFIXES.some((suffix) => segment.endsWith(suffix)) + ); + +const getPrebuiltDirectoryName = (key: { + buildInputsHash: string; + destination: XCTestAgentBuildDestination; + sdkVersion: string; + xcodeVersion: string; +}): string => { + const hash = createHash('sha256'); + hash.update(key.destination); + hash.update('\0'); + hash.update(key.buildInputsHash); + hash.update('\0'); + hash.update(key.xcodeVersion); + hash.update('\0'); + hash.update(key.sdkVersion); + + return `${key.destination}-${hash.digest('hex').slice(0, 12)}`; +}; + +/** + * Adds prebuilt XCTest agent artifacts to the installed package, so consumers + * on a supported Xcode never run `xcodebuild build-for-testing`. + * + * An artifact is an unsigned `build-for-testing` product tree without debug + * symbol bundles. It embeds no device, bundle-identity, or checkout path, + * which is what makes it reusable across machines. + */ +export const packXCTestAgentArtifacts = async ( + options: PackXCTestAgentArtifactsOptions +): Promise => { + // The destination becomes part of the artifact's identity, and the build + // arguments fall through to device for anything that is not simulator. A typo + // would therefore pack a device build under a simulator label and publish it. + if (options.destination !== 'simulator' && options.destination !== 'device') { + throw new Error( + `Unsupported destination '${String( + options.destination + )}'. Expected 'simulator' or 'device'.` + ); + } + + const toolchain = await resolveXCTestAgentToolchain( + options.destination, + options.signal + ); + const buildInputsHash = getAgentProjectSourcesHash(); + const packageRoot = options.packageRoot ?? getPackageRootPath(); + const indexPath = getPrebuiltIndexPath(packageRoot); + const directoryName = getPrebuiltDirectoryName({ + buildInputsHash, + destination: options.destination, + sdkVersion: toolchain.sdkVersion, + xcodeVersion: toolchain.xcodeVersion, + }); + const artifactDirectoryPath = path.join( + getPrebuiltRootPath(packageRoot), + directoryName + ); + + // A fresh derived data folder, never a cached local build: a cache in this + // checkout may hold products signed with the packer's own identity and + // profile, and those must not reach a published package. + const derivedDataPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'harness-xctest-pack-') + ); + + try { + const xctestrunRelativePath = await runXCTestAgentBuild({ + derivedDataPath, + destination: options.destination, + signal: options.signal, + }); + + fs.rmSync(artifactDirectoryPath, { recursive: true, force: true }); + fs.cpSync( + getXCTestAgentBuildProductsPath(derivedDataPath), + path.join(artifactDirectoryPath, 'Build', 'Products'), + { filter: shouldCopyArtifactPath, recursive: true } + ); + assertArtifactIsAnonymous(artifactDirectoryPath, [ + derivedDataPath, + getPackageRootPath(), + packageRoot, + os.homedir(), + ]); + + return writePackedEntry({ + agentVersion: options.agentVersion, + artifactDirectoryPath, + buildInputsHash, + destination: options.destination, + directoryName, + indexPath, + toolchain, + xctestrunRelativePath, + }); + } catch (error) { + fs.rmSync(artifactDirectoryPath, { recursive: true, force: true }); + throw error; + } finally { + fs.rmSync(derivedDataPath, { recursive: true, force: true }); + } +}; + +const writePackedEntry = (options: { + agentVersion?: string; + artifactDirectoryPath: string; + buildInputsHash: string; + destination: XCTestAgentBuildDestination; + directoryName: string; + indexPath: string; + toolchain: { sdkVersion: string; xcodeBuild: string; xcodeVersion: string }; + xctestrunRelativePath: string; +}): PackXCTestAgentArtifactsResult => { + const { toolchain } = options; + const entry: XCTestAgentPrebuiltEntry = { + agentVersion: options.agentVersion ?? getPackageVersion(), + buildInputsHash: options.buildInputsHash, + destination: options.destination, + directoryName: options.directoryName, + sdkVersion: toolchain.sdkVersion, + xctestrunRelativePath: options.xctestrunRelativePath, + xcodeBuild: toolchain.xcodeBuild, + xcodeVersion: toolchain.xcodeVersion, + }; + const existing = readPrebuiltIndex(options.indexPath); + // One artifact per destination: it serves every supported Xcode, so packing + // again replaces it rather than adding a sibling. + const entries = (existing?.entries ?? []).filter( + (candidate) => candidate.destination !== entry.destination + ); + + const index: XCTestAgentPrebuiltIndex = { + entries: [...entries, entry].sort((a, b) => + a.directoryName.localeCompare(b.directoryName) + ), + schemaVersion: PREBUILT_INDEX_SCHEMA_VERSION, + }; + + writePrebuiltIndex(options.indexPath, index); + removeUnindexedArtifacts(path.dirname(options.indexPath), index); + + xctestAgentLogger.info( + 'Packed prebuilt XCTest agent artifacts for %s (Xcode %s, SDK %s) into %s', + options.destination, + entry.xcodeVersion, + entry.sdkVersion, + options.artifactDirectoryPath + ); + + return { + artifactDirectoryPath: options.artifactDirectoryPath, + entry, + indexFilePath: options.indexPath, + }; +}; + +/** + * Deletes artifact directories the index no longer names. Packing replaces the + * entry for a destination, so without this a directory packed by an earlier + * Xcode would stay on disk and ship in the tarball as dead weight. + */ +const removeUnindexedArtifacts = ( + prebuiltRootPath: string, + index: XCTestAgentPrebuiltIndex +) => { + const indexed = new Set(index.entries.map((entry) => entry.directoryName)); + + for (const entry of fs.readdirSync(prebuiltRootPath, { + withFileTypes: true, + })) { + if (entry.isDirectory() && !indexed.has(entry.name)) { + fs.rmSync(path.join(prebuiltRootPath, entry.name), { + recursive: true, + force: true, + }); + } + } +}; + +const listFiles = (root: string): string[] => + fs + .readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(entry.parentPath, entry.name)); + +// Team that signs the `XCTRunner` host Xcode copies into every test runner. +const APPLE_TEAM_IDENTIFIER = '59GAB85EFG'; + +/** + * Fails the pack when an artifact carries anything tied to the machine or the + * Apple Developer account it was packed on. + * + * Releases are packed on a maintainer's machine, so a signed product, an + * embedded profile, or an absolute path would publish their identity with the + * package. The build flags already keep these out; this check is what makes + * that a guarantee instead of an assumption about Xcode's defaults. + */ +const assertArtifactIsAnonymous = ( + artifactDirectoryPath: string, + hostPaths: string[] +) => { + const needles = [ + ...new Set( + hostPaths.flatMap((hostPath) => [hostPath, fs.realpathSync(hostPath)]) + ), + ].map((needle) => Buffer.from(needle)); + const problems: string[] = []; + + for (const filePath of listFiles(artifactDirectoryPath)) { + const relativePath = path.relative(artifactDirectoryPath, filePath); + + if (filePath.endsWith('.mobileprovision') || filePath.endsWith('.xcent')) { + problems.push(`${relativePath} is signing material`); + continue; + } + + const contents = fs.readFileSync(filePath); + + if (needles.some((needle) => contents.includes(needle))) { + problems.push(`${relativePath} embeds a path from this machine`); + } + } + + for (const bundlePath of fs + .readdirSync(artifactDirectoryPath, { + recursive: true, + withFileTypes: true, + }) + .filter( + (entry) => + entry.isDirectory() && + (entry.name.endsWith('.app') || entry.name.endsWith('.xctest')) + ) + .map((entry) => path.join(entry.parentPath, entry.name))) { + const signature = spawnSync('codesign', ['-dv', bundlePath], { + encoding: 'utf8', + }); + const teamIdentifier = /^TeamIdentifier=(.+)$/m.exec(signature.stderr)?.[1]; + + if ( + teamIdentifier && + teamIdentifier !== 'not set' && + teamIdentifier !== APPLE_TEAM_IDENTIFIER + ) { + problems.push( + `${path.relative( + artifactDirectoryPath, + bundlePath + )} is signed by team ${teamIdentifier}` + ); + } + } + + if (problems.length > 0) { + throw new Error( + `Refusing to pack XCTest agent artifacts that identify this machine or account:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}` + ); + } +}; + +type UnpackMarker = { + directoryName: string; + xctestrunRelativePath: string; +}; + +const readUnpackMarker = (markerPath: string): UnpackMarker | null => { + if (!fs.existsSync(markerPath)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(markerPath, 'utf8')) as UnpackMarker; + } catch { + return null; + } +}; + +const isUnpacked = ( + derivedDataPath: string, + directoryName: string, + entry: XCTestAgentPrebuiltEntry +): boolean => + readUnpackMarker(path.join(derivedDataPath, PREBUILT_CACHE_MARKER)) + ?.directoryName === directoryName && + fs.existsSync( + path.join( + getXCTestAgentBuildProductsPath(derivedDataPath), + entry.xctestrunRelativePath + ) + ); + +/** + * Copies an artifact out of the package into the project's Harness cache. + * + * The copy exists because `test-without-building` writes results and logs + * into its derived data path, and on a physical device that copy is also + * re-signed, while the installed package is a shared store that must stay + * pristine. Unpacking happens once per project and variant. + * + * The copy is assembled in a staging directory, finished by `prepare`, marked, + * and only then renamed into place. A run that is interrupted mid-signing, or + * two runners unpacking the same variant at once, therefore never leave or + * observe a half-prepared copy. + */ +const unpackPrebuiltArtifacts = async (options: { + artifactDirectoryPath: string; + entry: XCTestAgentPrebuiltEntry; + prepare?: (derivedDataPath: string) => Promise; + projectRoot: string; + /** + * Distinguishes caches that must not be shared, such as one re-signed for a + * particular identity and profile. The packaged artifact itself is never + * mutated, so the unpacked copy has to be keyed by whatever was signed into it. + */ + variant?: string; +}): Promise => { + const { entry } = options; + const directoryName = options.variant + ? `${entry.directoryName}-${createHash('sha256') + .update(options.variant) + .digest('hex') + .slice(0, 8)}` + : entry.directoryName; + const derivedDataPath = path.join( + getHarnessCacheRootPath(options.projectRoot), + `${PREBUILT_CACHE_PREFIX}-${directoryName}` + ); + + if (isUnpacked(derivedDataPath, directoryName, entry)) { + return derivedDataPath; + } + + const stagingPath = `${derivedDataPath}.staging-${process.pid}-${Date.now()}`; + + try { + fs.mkdirSync(path.dirname(derivedDataPath), { recursive: true }); + fs.cpSync(options.artifactDirectoryPath, stagingPath, { recursive: true }); + await options.prepare?.(stagingPath); + fs.writeFileSync( + path.join(stagingPath, PREBUILT_CACHE_MARKER), + JSON.stringify( + { + directoryName, + xctestrunRelativePath: entry.xctestrunRelativePath, + } satisfies UnpackMarker, + null, + 2 + ) + ); + + // Another runner may have finished the same variant meanwhile; its copy + // is equivalent, so keep it rather than replacing a directory in use. + if (isUnpacked(derivedDataPath, directoryName, entry)) { + return derivedDataPath; + } + + fs.rmSync(derivedDataPath, { recursive: true, force: true }); + fs.renameSync(stagingPath, derivedDataPath); + + return derivedDataPath; + } finally { + fs.rmSync(stagingPath, { recursive: true, force: true }); + } +}; + +export type PreparedPrebuiltArtifacts = { + /** Project-local copy of the artifact, already through `prepare`. */ + derivedDataPath: string; + entry: XCTestAgentPrebuiltEntry; +}; + +export const toPrebuiltArtifacts = ( + prepared: PreparedPrebuiltArtifacts +): XCTestAgentArtifacts => ({ + launch: { + derivedDataPath: prepared.derivedDataPath, + kind: 'xctestrun', + xctestrunPath: path.join( + getXCTestAgentBuildProductsPath(prepared.derivedDataPath), + prepared.entry.xctestrunRelativePath + ), + }, + source: 'prebuilt', +}); + +/** + * Finds the shipped artifact the local toolchain may use, or returns `null` when + * none qualifies. + * + * Declining rather than failing is deliberate: an Xcode older than the + * supported minimum, or a package whose agent sources were changed locally, is + * expected, and the caller's fallback — build locally — is always correct. + */ +export const resolvePrebuiltEntry = async (options: { + destination: XCTestAgentBuildDestination; + packageRoot?: string; + signal?: AbortSignal; +}): Promise => { + if (process.env[XCTEST_SKIP_PREBUILDS_ENV] === '1') { + xctestAgentLogger.debug( + '%s is set, ignoring prebuilt XCTest agent artifacts', + XCTEST_SKIP_PREBUILDS_ENV + ); + return null; + } + + const packageRoot = options.packageRoot ?? resolvePrebuiltPackageRoot(); + const index = readPrebuiltIndex(getPrebuiltIndexPath(packageRoot)); + const candidates = index?.entries.filter( + (candidate) => candidate.destination === options.destination + ); + + if (!candidates?.length) { + xctestAgentLogger.debug( + 'no prebuilt XCTest agent artifacts shipped for %s', + options.destination + ); + return null; + } + + let toolchain; + + try { + toolchain = await resolveXCTestAgentToolchain( + options.destination, + options.signal + ); + } catch (error) { + xctestAgentLogger.debug( + 'skipping prebuilt XCTest agent artifacts, the local toolchain version could not be read: %s', + error instanceof Error ? error.message : String(error) + ); + return null; + } + + const buildInputsHash = getAgentProjectSourcesHash(); + const agentVersion = getPackageVersion(); + const current = candidates.filter( + (candidate) => + candidate.buildInputsHash === buildInputsHash && + candidate.agentVersion === agentVersion + ); + + if (current.length === 0) { + xctestAgentLogger.info( + 'Shipped XCTest agent artifacts were packed from different agent sources, building instead' + ); + return null; + } + + const entry = current.find((candidate) => + supportsXcode(candidate, toolchain.xcodeVersion) + ); + + if (!entry) { + xctestAgentLogger.info( + 'Shipped XCTest agent artifacts support Xcode %d up to Xcode %s, found Xcode %s, building instead', + MIN_PREBUILT_XCODE_MAJOR_VERSION, + current + .map((candidate) => getXcodeMajorVersion(candidate.xcodeVersion)) + .sort((a, b) => b - a)[0], + toolchain.xcodeVersion + ); + return null; + } + + const artifactDirectoryPath = path.join( + getPrebuiltRootPath(packageRoot), + entry.directoryName + ); + + if ( + !fs.existsSync( + path.join( + getXCTestAgentBuildProductsPath(artifactDirectoryPath), + entry.xctestrunRelativePath + ) + ) + ) { + xctestAgentLogger.warn( + 'Prebuilt XCTest agent artifacts are incomplete at %s, building instead', + artifactDirectoryPath + ); + return null; + } + + return entry; +}; + +/** + * Copies a resolved artifact into the project's Harness cache, running + * `prepare` on the copy before it becomes visible. + * + * `variant` separates caches whose contents differ for reasons the artifact key + * cannot see, such as re-signing for a particular identity. The packaged copy is + * never modified, so anything `prepare` writes has to be part of the variant. + */ +export const unpackPrebuiltEntry = async ( + entry: XCTestAgentPrebuiltEntry, + options: { + packageRoot?: string; + prepare?: (derivedDataPath: string) => Promise; + projectRoot: string; + variant?: string; + } +): Promise => ({ + derivedDataPath: await unpackPrebuiltArtifacts({ + artifactDirectoryPath: path.join( + getPrebuiltRootPath(options.packageRoot ?? resolvePrebuiltPackageRoot()), + entry.directoryName + ), + entry, + prepare: options.prepare, + projectRoot: options.projectRoot, + variant: options.variant, + }), + entry, +}); + +/** + * Resolves a usable shipped artifact and copies it into the project cache, or + * returns `null` when none qualifies. + */ +const preparePrebuiltArtifacts = async (options: { + destination: XCTestAgentBuildDestination; + packageRoot?: string; + projectRoot: string; + signal?: AbortSignal; +}): Promise => { + const entry = await resolvePrebuiltEntry(options); + + if (!entry) { + return null; + } + + return await unpackPrebuiltEntry(entry, options); +}; + +/** + * Provider for simulator runs: the shipped artifact is directly runnable, since + * simulator builds carry no signature. + */ +export const createPrebuiltXCTestAgentProvider = (options?: { + packageRoot?: string; +}): XCTestAgentProvider => ({ + id: 'prebuilt', + prepare: async (context) => { + if (context.destination !== 'simulator') { + return null; + } + + const prepared = await preparePrebuiltArtifacts({ + ...options, + destination: context.destination, + projectRoot: context.projectRoot, + signal: context.signal, + }); + + if (!prepared) { + return null; + } + + xctestAgentLogger.info( + 'Using prebuilt XCTest agent artifacts for %s target (Xcode %s)', + context.destination, + prepared.entry.xcodeVersion + ); + + return toPrebuiltArtifacts(prepared); + }, +}); diff --git a/packages/platform-ios/src/xctest-agent-resign.ts b/packages/platform-ios/src/xctest-agent-resign.ts new file mode 100644 index 00000000..edeecea5 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-resign.ts @@ -0,0 +1,1073 @@ +import { logger, spawn } from '@react-native-harness/tools'; +import { spawnSync } from 'node:child_process'; +import { X509Certificate } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { getXCTestAgentBuildProductsPath } from './xctest-agent-build.js'; +import { + resolvePrebuiltEntry, + toPrebuiltArtifacts, + unpackPrebuiltEntry, +} from './xctest-agent-prebuilt.js'; +import type { + XCTestAgentProvider, + XCTestAgentProviderContext, +} from './xctest-agent-artifacts.js'; + +const xctestAgentLogger = logger.child('ios-xctest-agent'); + +const DEFAULT_SIGNING_IDENTITY = 'Apple Development'; + +let usableIdentityHashes: string[] | undefined; + +/** + * SHA-1 fingerprints of the codesigning identities this machine can sign with. + * + * `-v` does not mean "usable": it still lists revoked and otherwise invalid + * identities, merely appending a status such as `(CSSMERR_TP_CERT_REVOKED)` + * instead of the end of the line. Only an unannotated identity is usable, and + * the distinction matters because `codesign` accepts a revoked SHA-1 and fails + * partway through signing rather than when the identity is chosen. + */ +const getUsableIdentityHashes = (): string[] => { + if (usableIdentityHashes) { + return usableIdentityHashes; + } + + const found = spawnSync('security', [ + 'find-identity', + '-v', + '-p', + 'codesigning', + ]); + + usableIdentityHashes = + found.status === 0 + ? found.stdout + .toString() + .split('\n') + .map( + (line) => + // Anchored after the closing quote, so a line trailing an + // "(CSSMERR_…)" annotation matches nothing. + /^\s*\d+\)\s+([0-9A-F]{40})\s+"[^"]*"$/.exec(line.trimEnd())?.[1] + ) + .filter((hash): hash is string => hash !== undefined) + : []; + + return usableIdentityHashes; +}; + +/** + * Chooses the `codesign` identity to sign with. + * + * An explicit `signingIdentity` always wins. Otherwise the profile's own + * embedded certificates are matched against the usable keychain identities, + * which is both precise and cheap: a profile lists exactly the certificates it + * can be used with, so the intersection cannot name another account's + * certificate. Matching by name is not an option — `codesign --sign "Apple + * Development"` fails with "ambiguous (matches …)" as soon as a keychain holds + * two development certificates, which is routine for a personal and a work + * account on one machine. + */ +export const resolveSigningIdentity = (options: { + identity?: string; + profile: ProvisioningProfile; +}): { error?: string; identity: string } => { + if (options.identity) { + return { identity: options.identity }; + } + + const usable = getUsableIdentityHashes(); + const candidates = options.profile.developerCertificateHashes.filter((hash) => + usable.includes(hash) + ); + + if (candidates.length === 1) { + return { identity: candidates[0] }; + } + + if (candidates.length === 0) { + return { + error: `no usable "Apple Development" certificate in the keychain matches the provisioning profile "${options.profile.name}"`, + identity: DEFAULT_SIGNING_IDENTITY, + }; + } + + return { + error: + `several keychain certificates match the provisioning profile ` + + `"${options.profile.name}", so set device.codeSign.signingIdentity to ` + + `one of ${candidates.join(', ')}`, + identity: DEFAULT_SIGNING_IDENTITY, + }; +}; +const AGENT_BUNDLE_ID_PREFIX = 'com.callstackincubator.'; + +export type ProvisioningProfile = { + /** `application-identifier` entitlement, e.g. `TEAMID.*`. */ + applicationIdentifier: string; + /** Certificates this profile may be used with, as codesign SHA-1 fingerprints. */ + developerCertificateHashes: string[]; + name: string; + path: string; + provisionedDevices: string[]; + teamId: string; + uuid: string; +}; + +export type FindProvisioningProfileOptions = { + /** + * Prefix the artifacts will be remapped to, when the consumer's profile + * cannot cover the agent's own bundle ids. Omitted when searching for a + * profile that covers the shipped ids as-is. + */ + bundleIdPrefix?: string; + /** Device the agent must run on; profiles listing devices must include it. */ + deviceId?: string; + /** Path, name, or UUID of a profile to use. Otherwise any installed match. */ + profile?: string; + /** + * Requested `codesign` identity. A SHA-1 restricts profiles to those that name + * that certificate; a name is too coarse to restrict anything, since several + * certificates share the display name "Apple Development". + */ + signingIdentity?: string; + signal?: AbortSignal; + teamId: string; +}; + +const getProfileSearchPaths = (): string[] => { + const home = os.homedir(); + + return [ + path.join( + home, + 'Library', + 'Developer', + 'Xcode', + 'UserData', + 'Provisioning Profiles' + ), + path.join(home, 'Library', 'MobileDevice', 'Provisioning Profiles'), + ]; +}; + +/** + * `application-identifier` values the agent must be allowed to claim: the + * app-under-test id and the test runner host id, team-qualified. + * + * Profiles grant this entitlement rather than bare bundle ids, so coverage is + * checked against these. Remapping changes the prefix, so the lookup is asked + * about the ids the agent will actually be signed with — the XCTest bundle + * itself is signed without entitlements and needs no coverage. + */ +export const getAgentApplicationIdentifiers = (options: { + bundleIdPrefix?: string; + teamId: string; +}): string[] => { + const prefix = options.bundleIdPrefix + ? `${options.bundleIdPrefix.replace(/\.$/, '')}.` + : AGENT_BUNDLE_ID_PREFIX; + + return [ + `${options.teamId}.${prefix}HarnessXCTestAgent`, + `${options.teamId}.${prefix}HarnessXCTestAgentUITests.xctrunner`, + ]; +}; + +const readPlistJson = (plistPath: string, keyPath: string): T | null => { + const result = spawnSync('plutil', [ + '-extract', + keyPath, + 'json', + '-o', + '-', + plistPath, + ]); + + if (result.status !== 0) { + return null; + } + + try { + return JSON.parse(result.stdout.toString()) as T; + } catch { + return null; + } +}; + +/** + * The fingerprint `security find-identity` and `codesign` name a certificate + * by: SHA-1 over its DER bytes, uppercase hex without separators. It identifies + * the certificate; it does not protect anything. + */ +const getCertificateFingerprint = (der: Buffer): string => + new X509Certificate(der).fingerprint.replaceAll(':', ''); + +/** + * Certificates a profile may be used with, as codesign SHA-1 fingerprints. + * + * Each entry is the DER certificate, which `plutil` prints as base64 when + * extracted on its own. + */ +const readDeveloperCertificateHashes = (plistPath: string): string[] => { + const hashes: string[] = []; + + for (let index = 0; index < MAX_DEVELOPER_CERTIFICATES; index += 1) { + const certificate = spawnSync('plutil', [ + '-extract', + `DeveloperCertificates.${index}`, + 'raw', + '-o', + '-', + plistPath, + ]); + + if (certificate.status !== 0) { + break; + } + + const der = Buffer.from(certificate.stdout.toString().trim(), 'base64'); + + if (der.length === 0) { + continue; + } + + try { + hashes.push(getCertificateFingerprint(der)); + } catch { + // A blob that is not a certificate cannot match a keychain identity. + } + } + + return hashes; +}; + +/** + * Reads a provisioning profile's signing-relevant fields. + * + * The profile is a CMS message, and its decoded payload holds binary + * `DeveloperCertificates` blobs that `plutil -convert json` refuses to + * represent. So fields are extracted one key at a time rather than by + * converting the file, which also puts the cheap team check first: a profile + * from another account costs one extraction instead of six. + */ +const decodeProvisioningProfile = async ( + profilePath: string, + signal?: AbortSignal +): Promise => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-profile-')); + const decoded = path.join(directory, 'decoded.plist'); + + try { + const { stdout } = await spawn( + 'security', + ['cms', '-D', '-i', profilePath], + { signal } + ); + fs.writeFileSync(decoded, stdout); + + const teamId = readPlistString(decoded, 'TeamIdentifier.0'); + + if (!teamId) { + return null; + } + + const applicationIdentifier = readPlistString( + decoded, + 'Entitlements.application-identifier' + ); + + if (!applicationIdentifier) { + return null; + } + + // An unparseable date keeps the profile rather than discarding it: the + // device rejects expired provisioning on its own, and a false decline here + // would cost a full rebuild for a profile that is probably fine. + const expiration = Date.parse( + readPlistString(decoded, 'ExpirationDate') ?? '' + ); + + if (!Number.isNaN(expiration) && expiration <= Date.now()) { + xctestAgentLogger.debug( + 'ignoring expired provisioning profile %s', + profilePath + ); + return null; + } + + return { + applicationIdentifier, + developerCertificateHashes: readDeveloperCertificateHashes(decoded), + name: readPlistString(decoded, 'Name') ?? path.basename(profilePath), + path: profilePath, + provisionedDevices: + readPlistJson(decoded, 'ProvisionedDevices') ?? [], + teamId, + uuid: readPlistString(decoded, 'UUID') ?? '', + }; + } catch { + return null; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}; + +/** + * Whether a profile's `application-identifier` covers a value: `TEAM.*` covers + * the team's whole namespace, `TEAM.a.*` a subtree, anything else only an exact + * match. + */ +const coversApplicationIdentifier = ( + profile: ProvisioningProfile, + applicationIdentifier: string +) => { + const granted = profile.applicationIdentifier; + + if (granted === applicationIdentifier) { + return true; + } + + return granted.endsWith('*') + ? applicationIdentifier.startsWith(granted.slice(0, -1)) + : false; +}; + +/** + * Finds an installed profile that can sign the agent without contacting the + * Developer Portal, which is what makes re-signing usable offline and in CI. + */ +export const findProvisioningProfile = async ( + options: FindProvisioningProfileOptions +): Promise => { + const required = getAgentApplicationIdentifiers(options); + + /** + * A profile is only worth choosing when a certificate it names is installed + * and unrevoked. Otherwise signing is guaranteed to fail later, and on + * machines with several accounts another profile is often usable. + */ + // A certificate name cannot restrict anything: several certificates answer to + // "Apple Development", so only a SHA-1 fingerprint is treated as a filter. + const identityFilter = + options.signingIdentity && /^[0-9a-fA-F]{40}$/.test(options.signingIdentity) + ? options.signingIdentity.toUpperCase() + : undefined; + const isSignable = (profile: ProvisioningProfile) => + identityFilter + ? profile.developerCertificateHashes.includes(identityFilter) + : profile.developerCertificateHashes.some((hash) => + // Only read the keychain when the caller did not name a certificate. + getUsableIdentityHashes().includes(hash) + ); + + /** + * Everything a profile must satisfy to be chosen, for a named profile as well + * as a scanned one. Both paths have to ask the same questions: a profile given + * by path is still the thing that gets installed on this device, so leaving the + * device check to the scan would let an explicit path sign for a device the + * profile does not list — a failure that only surfaces as an install error. + */ + const isUsable = (profile: ProvisioningProfile) => + profile.teamId === options.teamId && + isSignable(profile) && + required.every((identifier) => + coversApplicationIdentifier(profile, identifier) + ) && + (!options.deviceId || + profile.provisionedDevices.includes(options.deviceId)); + + if (options.profile && fs.existsSync(options.profile)) { + const profile = await decodeProvisioningProfile( + options.profile, + options.signal + ); + + return profile && isUsable(profile) ? profile : null; + } + + const candidates: ProvisioningProfile[] = []; + + for (const searchPath of getProfileSearchPaths()) { + if (!fs.existsSync(searchPath)) { + continue; + } + + for (const entry of fs.readdirSync(searchPath)) { + if (!entry.endsWith('.mobileprovision')) { + continue; + } + + const profile = await decodeProvisioningProfile( + path.join(searchPath, entry), + options.signal + ); + + if (profile && isUsable(profile)) { + candidates.push(profile); + } + } + } + + if (options.profile) { + const named = candidates.find( + (candidate) => + candidate.name === options.profile || candidate.uuid === options.profile + ); + + if (!named) { + xctestAgentLogger.info( + 'No installed provisioning profile named %s covers the XCTest agent for team %s', + options.profile, + options.teamId + ); + } + + return named ?? null; + } + + // Prefer the most specific match: a profile that enumerates devices is a + // better fit than a wildcard whose device list can silently fall behind a + // newly added device. + return ( + candidates.sort( + (a, b) => + b.provisionedDevices.length - a.provisionedDevices.length || + b.applicationIdentifier.length - a.applicationIdentifier.length + )[0] ?? null + ); +}; + +const readPlistString = (plistPath: string, keyPath: string): string | null => { + const result = spawnSync('plutil', [ + '-extract', + keyPath, + 'raw', + '-o', + '-', + plistPath, + ]); + + return result.status === 0 ? result.stdout.toString().trim() || null : null; +}; + +const replacePlistString = ( + plistPath: string, + keyPath: string, + value: string +): void => { + const result = spawnSync('plutil', [ + '-replace', + keyPath, + '-string', + value, + plistPath, + ]); + + if (result.status !== 0) { + throw new Error( + `Unable to set ${keyPath} in ${plistPath}: ${result.stderr + .toString() + .trim()}` + ); + } +}; + +const MAX_DEVELOPER_CERTIFICATES = 32; + +/** The fields of a `.xctestrun` test target that signing and remapping use. */ +type XCTestRunTarget = { + TestBundlePath?: string; + TestHostBundleIdentifier?: string; + TestHostPath?: string; + UITargetAppPath?: string; +}; + +/** + * Every test target of every configuration, with the plist key path it lives + * at, so a target can be rewritten in place with `plutil -replace`. + * + * Xcode emits one configuration with one target for this scheme, but reading + * all of them means a file that ever carried two cannot make this code sign + * one bundle while the session launches another. A `.xctestrun` holds only + * strings, numbers, booleans, and collections, so it converts to JSON whole. + */ +const readXCTestRunTargets = ( + xctestrunPath: string +): { keyPath: string; target: XCTestRunTarget }[] => { + const result = spawnSync('plutil', [ + '-convert', + 'json', + '-o', + '-', + xctestrunPath, + ]); + + if (result.status !== 0) { + throw new Error( + `Unable to read ${xctestrunPath}: ${result.stderr.toString().trim()}` + ); + } + + const xctestrun = JSON.parse(result.stdout.toString()) as { + TestConfigurations?: { TestTargets?: XCTestRunTarget[] }[]; + }; + + return (xctestrun.TestConfigurations ?? []).flatMap( + (configuration, configurationIndex) => + (configuration.TestTargets ?? []).map((target, targetIndex) => ({ + keyPath: `TestConfigurations.${configurationIndex}.TestTargets.${targetIndex}`, + target, + })) + ); +}; + +/** + * Bundles named by a `.xctestrun`, resolved through its relocation macros. + * + * Reading the paths out of the file rather than globbing for `*.app` keeps this + * correct when the layout changes; `TestHostPath` and `UITargetAppPath` are what + * the test session itself installs and launches. + * + * The macros are relative to each other: `__TESTROOT__` is the products directory + * the `.xctestrun` sits in, while `__TESTHOST__` is the resolved `TestHostPath`, + * which is where the nested XCTest bundle lives. Treating `__TESTHOST__` as the + * products directory would look for the test bundle outside the runner and leave + * it unsigned, which the device rejects at install time. + */ +const getArtifactBundles = (xctestrunPath: string) => { + const productsPath = path.dirname(xctestrunPath); + const targets = readXCTestRunTargets(xctestrunPath); + const first = (key: 'TestHostPath' | 'TestBundlePath' | 'UITargetAppPath') => + targets.find(({ target }) => target[key])?.target[key]; + const rawRunnerPath = first('TestHostPath'); + + if (!rawRunnerPath) { + throw new Error( + `Prebuilt XCTest agent artifacts have no test host next to ${xctestrunPath}.` + ); + } + + const runnerPath = path.join( + productsPath, + rawRunnerPath.replace('__TESTROOT__', '.') + ); + + const resolve = (value: string | null | undefined) => + value + ? path.join( + productsPath, + value + .replace('__TESTROOT__', '.') + // A function replacement, so a path containing `$` is not read as a + // replacement pattern. + .replace('__TESTHOST__', () => + path.relative(productsPath, runnerPath) + ) + ) + : null; + + if (!fs.existsSync(runnerPath)) { + throw new Error( + `Prebuilt XCTest agent artifacts are incomplete next to ${xctestrunPath}: ${runnerPath} is missing.` + ); + } + + const appPath = resolve(first('UITargetAppPath')); + + if (!appPath || !fs.existsSync(appPath)) { + throw new Error( + `Prebuilt XCTest agent artifacts are incomplete next to ${xctestrunPath}: ${appPath} is missing.` + ); + } + + const pluginPath = resolve(first('TestBundlePath')); + + return { + appPath, + pluginPath: pluginPath && fs.existsSync(pluginPath) ? pluginPath : null, + runnerPath, + }; +}; + +const getBundleIdentifier = (bundlePath: string): string | null => + readPlistString(path.join(bundlePath, 'Info.plist'), 'CFBundleIdentifier'); + +/** + * Rewrites a plist array of bundle ids, leaving entries this remap does not + * know about alone. + */ +const remapBundleIdArray = (options: { + keyPath: string; + plistPath: string; + rewrites: ReadonlyMap; +}) => { + const extracted = spawnSync('plutil', [ + '-extract', + options.keyPath, + 'json', + '-o', + '-', + options.plistPath, + ]); + + if (extracted.status !== 0) { + return; + } + + let bundleIds: unknown; + + try { + bundleIds = JSON.parse(extracted.stdout.toString()); + } catch { + return; + } + + if (!Array.isArray(bundleIds)) { + return; + } + + const remapped = bundleIds.map((bundleId) => + typeof bundleId === 'string' + ? options.rewrites.get(bundleId) ?? bundleId + : bundleId + ); + + if (JSON.stringify(remapped) === JSON.stringify(bundleIds)) { + return; + } + + const result = spawnSync('plutil', [ + '-replace', + options.keyPath, + '-json', + JSON.stringify(remapped), + options.plistPath, + ]); + + if (result.status !== 0) { + throw new Error( + `Unable to set ${options.keyPath} in ${options.plistPath}: ${result.stderr + .toString() + .trim()}` + ); + } +}; + +/** + * Replaces the agent's bundle ids with ones under the consumer's own prefix. + * + * Accounts that cannot mint a profile covering `com.callstackincubator.*` — + * individual developer accounts, and many corporate ones — otherwise cannot run + * the agent at all. Rewriting the Info.plists is not enough: the `.xctestrun` + * names the runner bundle id per target and lists the ids whose crash reports + * belong to the agent, so a stale id there makes the test session look for an app + * that was never installed and attribute crashes to bundles that do not exist. + */ +const remapBundleIdentifiers = (options: { + bundles: string[]; + bundleIdPrefix: string; + xctestrunPath: string; +}) => { + const prefix = `${options.bundleIdPrefix.replace(/\.$/, '')}.`; + const rewrites = new Map(); + + for (const bundlePath of options.bundles) { + const bundleId = getBundleIdentifier(bundlePath); + + if (!bundleId?.startsWith(AGENT_BUNDLE_ID_PREFIX)) { + continue; + } + + const remapped = `${prefix}${bundleId.slice( + AGENT_BUNDLE_ID_PREFIX.length + )}`; + + replacePlistString( + path.join(bundlePath, 'Info.plist'), + 'CFBundleIdentifier', + remapped + ); + rewrites.set(bundleId, remapped); + } + + if (rewrites.size === 0) { + return; + } + + for (const { keyPath, target } of readXCTestRunTargets( + options.xctestrunPath + )) { + const current = target.TestHostBundleIdentifier; + const remapped = current ? rewrites.get(current) : undefined; + + if (remapped) { + replacePlistString( + options.xctestrunPath, + `${keyPath}.TestHostBundleIdentifier`, + remapped + ); + } + + remapBundleIdArray({ + keyPath: `${keyPath}.BundleIdentifiersForCrashReportEmphasis`, + plistPath: options.xctestrunPath, + rewrites, + }); + } +}; + +const writeEntitlements = (options: { + applicationIdentifier: string; + keychainAccessGroup?: string; + path: string; + teamId: string; +}) => { + const entries = [ + ` application-identifier${options.applicationIdentifier}`, + ` com.apple.developer.team-identifier${options.teamId}`, + ' get-task-allow', + ]; + + if (options.keychainAccessGroup) { + entries.push( + ` keychain-access-groups${options.keychainAccessGroup}` + ); + } + + fs.writeFileSync( + options.path, + [ + '', + '', + '', + ...entries, + '', + '', + ].join('\n') + ); +}; + +const signBundle = async (options: { + entitlementsPath?: string; + identity: string; + signal?: AbortSignal; + targetPath: string | string[]; +}) => { + const args = ['--force', '--sign', options.identity, '--timestamp=none']; + + if (options.entitlementsPath) { + args.push('--entitlements', options.entitlementsPath); + } + + await spawn('codesign', [...args, ...[options.targetPath].flat()], { + signal: options.signal, + }); +}; + +/** + * Frameworks and dylibs embedded in a bundle's `Frameworks` directory. + * + * From Xcode 26 the test runner embeds XCTest, XCUIAutomation, Testing, and + * their support libraries, signed by Apple. A device refuses to install an app + * whose embedded code is not signed by the same identity as the app itself + * ("The identity used to sign the executable is no longer valid"), which is + * why `xcodebuild` re-signs them too. + */ +const getEmbeddedCodePaths = (bundlePath: string): string[] => { + const frameworksPath = path.join(bundlePath, 'Frameworks'); + + if (!fs.existsSync(frameworksPath)) { + return []; + } + + return fs + .readdirSync(frameworksPath) + .filter((entry) => entry.endsWith('.framework') || entry.endsWith('.dylib')) + .sort() + .map((entry) => path.join(frameworksPath, entry)); +}; + +export type ResignXCTestAgentArtifactsOptions = { + /** Rewrites the agent's bundle ids; see `remapBundleIdentifiers`. */ + bundleIdPrefix?: string; + identity?: string; + profile: ProvisioningProfile; + signal?: AbortSignal; + xctestrunPath: string; +}; + +/** + * Signs unpacked device artifacts so the configured device can run them. + * + * Order matters: signing goes inside out, so embedded frameworks come first, + * then the XCTest bundle that lives inside the runner, then the runner that + * embeds it, then the app under test. Entitlements mirror what `xcodebuild` emits for the same team and bundle id — confirmed by + * diffing against a real signed build — because diverging would change which + * permission prompts the agent can observe. Signing takes about a second and + * needs no network, versus a rebuild. + */ +export const resignXCTestAgentArtifacts = async ( + options: ResignXCTestAgentArtifactsOptions +): Promise => { + const bundles = getArtifactBundles(options.xctestrunPath); + const identity = options.identity ?? DEFAULT_SIGNING_IDENTITY; + const teamId = options.profile.teamId; + + if (options.bundleIdPrefix) { + remapBundleIdentifiers({ + bundleIdPrefix: options.bundleIdPrefix, + bundles: [bundles.appPath, bundles.runnerPath, bundles.pluginPath].filter( + (bundle): bundle is string => Boolean(bundle) + ), + xctestrunPath: options.xctestrunPath, + }); + } + + const runnerBundleId = + getBundleIdentifier(bundles.runnerPath) ?? + `${AGENT_BUNDLE_ID_PREFIX}HarnessXCTestAgentUITests.xctrunner`; + const appBundleId = + getBundleIdentifier(bundles.appPath) ?? + `${AGENT_BUNDLE_ID_PREFIX}HarnessXCTestAgent`; + const temporary = fs.mkdtempSync( + path.join(os.tmpdir(), 'harness-entitlements-') + ); + + try { + const embeddedCode = [ + bundles.pluginPath, + bundles.runnerPath, + bundles.appPath, + ] + .filter((bundle): bundle is string => Boolean(bundle)) + .flatMap(getEmbeddedCodePaths); + + if (embeddedCode.length > 0) { + await signBundle({ + identity, + signal: options.signal, + targetPath: embeddedCode, + }); + } + + if (bundles.pluginPath) { + await signBundle({ + identity, + signal: options.signal, + targetPath: bundles.pluginPath, + }); + } + + const runnerEntitlements = path.join(temporary, 'runner.plist'); + fs.copyFileSync( + options.profile.path, + path.join(bundles.runnerPath, 'embedded.mobileprovision') + ); + writeEntitlements({ + applicationIdentifier: `${teamId}.${runnerBundleId}`, + keychainAccessGroup: `${teamId}.${runnerBundleId}`, + path: runnerEntitlements, + teamId, + }); + await signBundle({ + entitlementsPath: runnerEntitlements, + identity, + signal: options.signal, + targetPath: bundles.runnerPath, + }); + + const appEntitlements = path.join(temporary, 'app.plist'); + fs.copyFileSync( + options.profile.path, + path.join(bundles.appPath, 'embedded.mobileprovision') + ); + writeEntitlements({ + applicationIdentifier: `${teamId}.${appBundleId}`, + path: appEntitlements, + teamId, + }); + await signBundle({ + entitlementsPath: appEntitlements, + identity, + signal: options.signal, + targetPath: bundles.appPath, + }); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + + // A bundle that fails strict verification cannot launch, so fail here and let + // the caller rebuild rather than surface a cryptic device-side error. + await spawn( + 'codesign', + ['--verify', '--deep', '--strict', bundles.runnerPath], + { signal: options.signal } + ); +}; + +/** + * Chooses the profile and bundle ids to sign with. + * + * An explicit `bundleIdPrefix` always decides the bundle ids, even when a + * profile would also cover the agent's own ones. Signing otherwise or not based + * on which profiles happen to be installed would make the agent's identity on a + * device change as a side effect of unrelated Xcode activity, and the test + * session's own bookkeeping — crash attribution, existing installs — keys off it. + * + * Without a prefix the shipped ids are used, and no profile that covers them + * ends the attempt: the caller falls back to a local build, which can still + * reach the Developer Portal to mint one. + */ +const resolveSigningProfile = async ( + context: XCTestAgentProviderContext, + findProfile: ( + options: FindProvisioningProfileOptions + ) => Promise +) => { + const teamId = context.signing?.teamId; + + if (!teamId) { + return null; + } + + const bundleIdPrefix = context.signing?.bundleIdPrefix; + const profile = await findProfile({ + bundleIdPrefix, + deviceId: context.deviceId, + profile: context.signing?.provisioningProfile, + signingIdentity: context.signing?.signingIdentity, + signal: context.signal, + teamId, + }); + + if (profile) { + return { bundleIdPrefix, profile }; + } + + xctestAgentLogger.info( + bundleIdPrefix + ? 'No installed provisioning profile for team %s covers %s.HarnessXCTestAgent*, building the agent instead' + : 'No installed provisioning profile for team %s covers the XCTest agent and no device.codeSign.bundleIdPrefix is configured, building the agent instead', + teamId, + ...(bundleIdPrefix ? [bundleIdPrefix] : []) + ); + + return null; +}; + +/** + * Provider for physical devices: takes the unsigned artifact shipped with the + * package and signs it locally for the configured device. + * + * Device artifacts cannot be shipped ready to run the way simulator ones can, + * because a runnable device build embeds the consumer's certificate and a + * profile that lists their device. Re-signing closes that gap offline in about a + * second, versus a rebuild that also needs the Developer Portal reachable when + * automatic signing is in use. + * + * It declines, leaving the local build to run, when no signing configuration or + * no usable profile exists. Verification of the signed bundle happens inside + * `resignXCTestAgentArtifacts`, so a bad profile surfaces as a fallback rather + * than a device-side failure. + */ +export const createResignedDeviceXCTestAgentProvider = (options?: { + /** + * Profile lookup, overridable so the signing path is testable without reading + * the machine's installed profiles. + */ + findProfile?: ( + options: FindProvisioningProfileOptions + ) => Promise; + packageRoot?: string; +}): XCTestAgentProvider => ({ + id: 'prebuilt-resigned-device', + prepare: async (context) => { + if (context.destination !== 'device') { + return null; + } + + // Checked before the profile lookup on purpose: finding a profile means + // decoding every installed `.mobileprovision`, which is the slowest thing + // this provider does and is pointless when no artifact matches the local + // toolchain or the consumer never asked for signing. + const entry = await resolvePrebuiltEntry({ + destination: context.destination, + packageRoot: options?.packageRoot, + signal: context.signal, + }); + + if (!entry) { + return null; + } + + const signing = await resolveSigningProfile( + context, + options?.findProfile ?? findProvisioningProfile + ); + + if (!signing) { + return null; + } + + // Resolved before any copying: an ambiguous or missing certificate cannot + // sign, and naming it here beats a codesign error three steps later. + const identity = resolveSigningIdentity({ + identity: context.signing?.signingIdentity, + profile: signing.profile, + }); + + if (identity.error) { + xctestAgentLogger.warn('%s Building the agent instead.', identity.error); + return null; + } + + // Identity and profile are baked into the copied bundles, so two runs that + // differ in either must not share an unpacked copy. + const variant = [ + signing.profile.uuid || signing.profile.path, + signing.bundleIdPrefix ?? '', + identity.identity, + ].join('\0'); + let prepared; + + try { + prepared = await unpackPrebuiltEntry(entry, { + packageRoot: options?.packageRoot, + // Signing runs on the staged copy, so a failure here leaves nothing + // half-signed behind and the next run starts from the pristine artifact. + prepare: (derivedDataPath) => + resignXCTestAgentArtifacts({ + bundleIdPrefix: signing.bundleIdPrefix, + identity: identity.identity, + profile: signing.profile, + signal: context.signal, + xctestrunPath: path.join( + getXCTestAgentBuildProductsPath(derivedDataPath), + entry.xctestrunRelativePath + ), + }), + projectRoot: context.projectRoot, + variant, + }); + } catch (error) { + context.signal?.throwIfAborted(); + xctestAgentLogger.warn( + 'Unable to re-sign prebuilt XCTest agent artifacts for the device (%s), building instead', + error instanceof Error ? error.message : String(error) + ); + return null; + } + + xctestAgentLogger.info( + 'Using prebuilt XCTest agent artifacts for device target, signed with %s', + signing.profile.name + ); + + return toPrebuiltArtifacts(prepared); + }, +}); diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index dd437620..73a065b8 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -1,21 +1,17 @@ import { createHarnessArtifactDirectory, - getHarnessCacheRootPath, getAvailablePort, delay as cancellableDelay, logger, - spawn, spawnOwnedProcess, waitForAbort, type OwnedProcess, type Subprocess, } from '@react-native-harness/tools'; import fs from 'node:fs'; -import { createHash } from 'node:crypto'; import { PassThrough, pipeline } from 'node:stream'; import { promisify } from 'node:util'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { createXCTestAgentClient, type XCTestAgentPermissionsConfiguration, @@ -23,11 +19,35 @@ import { import type { XCTestAgentTransport } from './xctest-agent-transport.js'; import { createDeviceXCTestAgentTransport } from './xctest-agent-transport-device.js'; import { createSimulatorXCTestAgentTransport } from './xctest-agent-transport-simulator.js'; +import { + resolveXCTestAgentArtifacts, + type XCTestAgentBuildSigning, + type XCTestAgentLaunchInput, + type XCTestAgentProvider, +} from './xctest-agent-artifacts.js'; +import { + createBuildXCTestAgentProvider, + getXCTestAgentDerivedDataPath, + getXCTestAgentProjectRoot, +} from './xctest-agent-build.js'; +import { createPrebuiltXCTestAgentProvider } from './xctest-agent-prebuilt.js'; +import { createResignedDeviceXCTestAgentProvider } from './xctest-agent-resign.js'; + +const pipelineAsync = promisify(pipeline); + +export type { + BuildXCTestAgentOptions, + BuildXCTestAgentResult, +} from './xctest-agent-build.js'; +export type { + XCTestAgentArtifactSource, + XCTestAgentBuildDestination, + XCTestAgentBuildSigning, +} from './xctest-agent-artifacts.js'; +export { buildXCTestAgent } from './xctest-agent-build.js'; const xctestAgentLogger = logger.child('ios-xctest-agent'); -const XCTEST_AGENT_PROJECT_NAME = 'HarnessXCTestAgent'; -const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent'; const XCTEST_AGENT_PORT_ENV = 'HARNESS_XCTEST_AGENT_PORT'; const XCTEST_AGENT_TICK_INTERVAL_MS_ENV = 'HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS'; @@ -38,33 +58,6 @@ const XCTEST_AGENT_DERIVED_DATA_PATH_ENV = 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH'; const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000; const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; -const HARNESS_DIRNAME = '.harness'; -const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; -const XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT = 'xctest-agent-simulator'; -const XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION = 1; -const pipelineAsync = promisify(pipeline); - -export type XCTestAgentBuildDestination = 'simulator' | 'device'; - -export type XCTestAgentBuildSigning = { - teamId?: string; - signingIdentity?: string; - provisioningProfile?: string; -}; - -export type BuildXCTestAgentOptions = { - destination: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; - projectRoot?: string; - signal?: AbortSignal; -}; - -export type BuildXCTestAgentResult = { - destination: XCTestAgentBuildDestination; - derivedDataPath: string; - reused: boolean; - xctestrunPath?: string; -}; type XCTestAgentTarget = | { @@ -88,33 +81,6 @@ export type XCTestAgentRuntimeConfiguration = { permissions: XCTestAgentPermissionsConfiguration; }; -type XCTestAgentBuildManifest = { - buildInputsHash: string; - destinationKind: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; -}; - -type SimulatorXCTestAgentCacheManifest = { - artifactName: typeof XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT; - buildInputsHash: string; - destinationKind: 'simulator'; - hostArchitecture: string; - schemaVersion: typeof XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION; - simulatorSdkVersion: string; - xcodeVersion: string; - xctestrunRelativePath: string; -}; - -type SimulatorXCTestAgentCacheContext = Omit< - SimulatorXCTestAgentCacheManifest, - 'artifactName' | 'destinationKind' | 'schemaVersion' | 'xctestrunRelativePath' ->; - -type ExternalXCTestConfiguration = { - derivedDataPath: string; - xctestrunFilePath: string; -}; - export type XCTestAgentController = { prepare: (signal?: AbortSignal) => Promise; ensureStarted: (signal?: AbortSignal) => Promise; @@ -122,44 +88,6 @@ export type XCTestAgentController = { dispose: () => Promise; }; -const getXCTestAgentProjectRoot = (): string => { - return fileURLToPath(new URL('../xctest-agent', import.meta.url)); -}; - -const getXCTestAgentProjectFilePath = (): string => { - return path.join( - getXCTestAgentProjectRoot(), - `${XCTEST_AGENT_PROJECT_NAME}.xcodeproj` - ); -}; - -const assertXCTestAgentProjectExists = () => { - const projectFilePath = getXCTestAgentProjectFilePath(); - - if (fs.existsSync(projectFilePath)) { - return; - } - - throw new Error( - `Missing checked-in XCTest agent project at ${projectFilePath}. Include the checked-in project in the package artifact.` - ); -}; - -const getXCTestAgentBuildRoot = (projectRoot = process.cwd()): string => { - return path.join(projectRoot, HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); -}; - -const getXCTestAgentCacheRoot = (projectRoot = process.cwd()): string => { - return getHarnessCacheRootPath(projectRoot); -}; - -const getXCTestAgentDerivedDataPath = ( - destination: XCTestAgentBuildDestination, - projectRoot = process.cwd() -): string => { - return path.join(getXCTestAgentBuildRoot(projectRoot), destination); -}; - const getEnvironmentPath = (name: string): string | null => { const value = process.env[name]?.trim(); @@ -170,566 +98,55 @@ const getEnvironmentPath = (name: string): string | null => { return value; }; -const getExternalXCTestRunFilePath = (): string | null => { - return getEnvironmentPath(XCTEST_AGENT_XCTESTRUN_FILE_ENV); -}; - -const getExternalDerivedDataPath = (): string | null => { - return getEnvironmentPath(XCTEST_AGENT_DERIVED_DATA_PATH_ENV); -}; - -const assertExternalXCTestRunFileExists = (filePath: string) => { - if (fs.existsSync(filePath)) { - return; - } - - throw new Error( - `Missing external XCTest run file at ${filePath}. Check ${XCTEST_AGENT_XCTESTRUN_FILE_ENV}.` - ); -}; - -const getExternalXCTestConfiguration = ( - fallbackDerivedDataPath: string -): ExternalXCTestConfiguration | null => { - const xctestrunFilePath = getExternalXCTestRunFilePath(); - - if (!xctestrunFilePath) { - return null; - } - - assertExternalXCTestRunFileExists(xctestrunFilePath); - - return { - derivedDataPath: getExternalDerivedDataPath() ?? fallbackDerivedDataPath, - xctestrunFilePath, - }; -}; - -const getXCTestAgentBuildManifestPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'build-manifest.json'); - -const getXCTestAgentCacheManifestPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'cache.json'); - -const getXCTestAgentBuildDestination = ( - destination: XCTestAgentBuildDestination -): string => { - if (destination === 'simulator') { - return 'generic/platform=iOS Simulator'; - } - - return 'generic/platform=iOS'; -}; - -const getXCTestAgentRunDestination = (target: XCTestAgentTarget): string => { - if (target.kind === 'simulator') { - return `platform=iOS Simulator,id=${target.id}`; - } - - return `platform=iOS,id=${target.id}`; -}; - -const getXCTestAgentBuildSigningArgs = ( - destination: XCTestAgentBuildDestination, - signing?: XCTestAgentBuildSigning -): string[] => { - if (destination === 'simulator') { - return [ - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', - ]; - } - - if (!signing) { - return ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']; - } - - const args: string[] = []; - - if (signing.teamId) { - args.push('CODE_SIGN_STYLE=Automatic'); - args.push(`DEVELOPMENT_TEAM=${signing.teamId}`); - - if (signing.signingIdentity) { - args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); - } else { - args.push('CODE_SIGN_IDENTITY=Apple Development'); - } - } else if (signing.signingIdentity) { - args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); - } - - if (signing.provisioningProfile) { - args.push(`PROVISIONING_PROFILE_SPECIFIER=${signing.provisioningProfile}`); - } - - return args; -}; - -const getXCTestAgentBuildProductsPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'Build', 'Products'); - -const getXCTestAgentSourceFilePath = (): string => { - return fileURLToPath(import.meta.url); -}; - -const readBuildManifest = ( - derivedDataPath: string -): XCTestAgentBuildManifest | null => { - const manifestPath = getXCTestAgentBuildManifestPath(derivedDataPath); - - if (!fs.existsSync(manifestPath)) { - return null; - } - - return JSON.parse( - fs.readFileSync(manifestPath, 'utf8') - ) as XCTestAgentBuildManifest; -}; - -const writeBuildManifest = ( - derivedDataPath: string, - manifest: XCTestAgentBuildManifest -) => { - fs.mkdirSync(derivedDataPath, { recursive: true }); - fs.writeFileSync( - getXCTestAgentBuildManifestPath(derivedDataPath), - JSON.stringify(manifest, null, 2) - ); -}; - -const getProjectInputFilePaths = (root: string): string[] => { - const entries = fs.readdirSync(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - if (entry.name === 'build' || entry.name === '.gitignore') { - continue; - } - - const entryPath = path.join(root, entry.name); - - if (entry.isDirectory()) { - files.push(...getProjectInputFilePaths(entryPath)); - continue; - } - - files.push(entryPath); - } - - return files.sort(); -}; - -const getProjectInputsHash = (): string => { - const projectRoot = getXCTestAgentProjectRoot(); - const hash = createHash('sha256'); - - for (const filePath of getProjectInputFilePaths(projectRoot)) { - hash.update(path.relative(projectRoot, filePath)); - hash.update('\0'); - hash.update(fs.readFileSync(filePath)); - hash.update('\0'); - } - - const sourceFilePath = getXCTestAgentSourceFilePath(); - hash.update(path.basename(sourceFilePath)); - hash.update('\0'); - hash.update(fs.readFileSync(sourceFilePath)); - hash.update('\0'); - - return hash.digest('hex'); -}; - -const getXCTestRunRelativePath = (derivedDataPath: string): string | null => { - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - if (!fs.existsSync(buildProductsPath)) { - return null; - } - - const entries = fs.readdirSync(buildProductsPath, { recursive: true }); - const xctestrunEntry = entries.find( - (entry) => typeof entry === 'string' && entry.endsWith('.xctestrun') - ); - - return typeof xctestrunEntry === 'string' ? xctestrunEntry : null; -}; - -const readSimulatorBuildManifest = ( - derivedDataPath: string -): SimulatorXCTestAgentCacheManifest | null => { - const manifestPath = getXCTestAgentCacheManifestPath(derivedDataPath); - - if (!fs.existsSync(manifestPath)) { - return null; - } - - const manifest = JSON.parse( - fs.readFileSync(manifestPath, 'utf8') - ) as Partial; - - if ( - manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || - manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || - manifest.destinationKind !== 'simulator' || - typeof manifest.buildInputsHash !== 'string' || - typeof manifest.hostArchitecture !== 'string' || - typeof manifest.simulatorSdkVersion !== 'string' || - typeof manifest.xcodeVersion !== 'string' || - typeof manifest.xctestrunRelativePath !== 'string' - ) { - return null; - } - - return manifest as SimulatorXCTestAgentCacheManifest; -}; - -const writeSimulatorBuildManifest = ( - derivedDataPath: string, - manifest: SimulatorXCTestAgentCacheManifest -) => { - fs.mkdirSync(derivedDataPath, { recursive: true }); - fs.writeFileSync( - getXCTestAgentCacheManifestPath(derivedDataPath), - JSON.stringify(manifest, null, 2) - ); -}; - -const getSimulatorCacheDirectoryName = ( - context: SimulatorXCTestAgentCacheContext -): string => { - const hash = createHash('sha256'); - hash.update(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); - hash.update('\0'); - hash.update(String(XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION)); - hash.update('\0'); - hash.update(context.buildInputsHash); - hash.update('\0'); - hash.update(context.hostArchitecture); - hash.update('\0'); - hash.update(context.simulatorSdkVersion); - hash.update('\0'); - hash.update(context.xcodeVersion); - - return `${XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT}-${hash - .digest('hex') - .slice(0, 12)}`; -}; - -const getSimulatorCacheDerivedDataPath = ( - context: SimulatorXCTestAgentCacheContext, - projectRoot = process.cwd() -): string => { - return path.join( - getXCTestAgentCacheRoot(projectRoot), - getSimulatorCacheDirectoryName(context) - ); -}; - -const getHarnessCacheDirectories = (projectRoot = process.cwd()): string[] => { - const cacheRoot = getXCTestAgentCacheRoot(projectRoot); - - if (!fs.existsSync(cacheRoot)) { - return []; - } - - return fs - .readdirSync(cacheRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(cacheRoot, entry.name)) - .sort(); -}; - -const isCompatibleSimulatorBuildManifest = ( - manifest: SimulatorXCTestAgentCacheManifest, - context: SimulatorXCTestAgentCacheContext -): boolean => { - return ( - manifest.buildInputsHash === context.buildInputsHash && - manifest.hostArchitecture === context.hostArchitecture && - manifest.simulatorSdkVersion === context.simulatorSdkVersion && - manifest.xcodeVersion === context.xcodeVersion - ); -}; - -const findReusableSimulatorBuildArtifacts = ( - context: SimulatorXCTestAgentCacheContext, - projectRoot = process.cwd() -): string | null => { - for (const derivedDataPath of getHarnessCacheDirectories(projectRoot)) { - const manifest = readSimulatorBuildManifest(derivedDataPath); - - if (!manifest || !isCompatibleSimulatorBuildManifest(manifest, context)) { - continue; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - if ( - fs.existsSync(buildProductsPath) && - fs.existsSync( - path.join(buildProductsPath, manifest.xctestrunRelativePath) - ) - ) { - return derivedDataPath; - } - } - - return null; -}; - -const getCurrentXcodeVersion = async ( - signal?: AbortSignal -): Promise => { - const { stdout } = await spawn('xcodebuild', ['-version'], { signal }); - return stdout.trim(); -}; - -const getCurrentSimulatorSdkVersion = async ( - signal?: AbortSignal -): Promise => { - const { stdout } = await spawn( - 'xcodebuild', - ['-version', '-sdk', 'iphonesimulator', 'SDKVersion'], - { signal } - ); - return stdout.trim(); -}; - -const getSimulatorCacheContext = async ( - buildInputsHash: string, - signal?: AbortSignal -): Promise => { - const [xcodeVersion, simulatorSdkVersion] = await Promise.all([ - getCurrentXcodeVersion(signal), - getCurrentSimulatorSdkVersion(signal), - ]); - - return { - buildInputsHash, - hostArchitecture: process.arch, - simulatorSdkVersion, - xcodeVersion, - }; -}; - -const shouldReuseBuildArtifacts = ( - options: { - buildInputsHash: string; - derivedDataPath: string; - destination: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; - } -): boolean => { - if (options.destination === 'simulator') { - throw new Error( - 'Simulator build reuse must be validated with cache compatibility metadata' - ); - } - - const manifest = readBuildManifest(options.derivedDataPath); - - if (!manifest) { - return false; - } - - if ( - manifest.buildInputsHash !== options.buildInputsHash || - manifest.destinationKind !== options.destination - ) { - return false; - } - - if ( - manifest.signing?.teamId !== options.signing?.teamId || - manifest.signing?.signingIdentity !== options.signing?.signingIdentity || - manifest.signing?.provisioningProfile !== options.signing?.provisioningProfile - ) { - return false; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath( - options.derivedDataPath - ); - - return fs.existsSync(buildProductsPath); -}; - -const getXCTestRunPath = (derivedDataPath: string): string | undefined => { - const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); - - if (!xctestrunRelativePath) { - return undefined; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - return path.join(buildProductsPath, xctestrunRelativePath); -}; - -export const buildXCTestAgent = async ( - options: BuildXCTestAgentOptions -): Promise => { - const projectRoot = options.projectRoot ?? process.cwd(); - const buildInputsHash = getProjectInputsHash(); - let derivedDataPath = getXCTestAgentDerivedDataPath( - options.destination, - projectRoot - ); - let simulatorCacheContext: SimulatorXCTestAgentCacheContext | undefined; - - xctestAgentLogger.debug( - 'verifying checked-in XCTest agent project for %s', - options.destination - ); - xctestAgentLogger.info( - 'Using checked-in XCTest agent project for %s target', - options.destination - ); - assertXCTestAgentProjectExists(); - - if (options.destination === 'simulator') { - simulatorCacheContext = await getSimulatorCacheContext( - buildInputsHash, - options.signal - ); - const reusableDerivedDataPath = findReusableSimulatorBuildArtifacts( - simulatorCacheContext, - projectRoot +/** + * Provider for artifacts prepared outside of Harness, pointed at with + * `HARNESS_IOS_XCTESTRUN_FILE`. This lets CI or a local script hand Harness a + * ready `.xctestrun` and skip artifact preparation entirely. + */ +const createExternalXCTestAgentProvider = (): XCTestAgentProvider => ({ + id: 'external', + prepare: (context) => { + const xctestrunFilePath = getEnvironmentPath( + XCTEST_AGENT_XCTESTRUN_FILE_ENV ); - if (reusableDerivedDataPath) { - xctestAgentLogger.info( - 'Reusing cached XCTest agent build for %s target', - options.destination - ); - xctestAgentLogger.debug( - 'reusing cached XCTest agent build for %s', - options.destination - ); - - return { - destination: options.destination, - derivedDataPath: reusableDerivedDataPath, - reused: true, - xctestrunPath: getXCTestRunPath(reusableDerivedDataPath), - }; + if (!xctestrunFilePath) { + return null; } - derivedDataPath = getSimulatorCacheDerivedDataPath( - simulatorCacheContext, - projectRoot - ); - } else { - const canReuseBuild = shouldReuseBuildArtifacts({ - buildInputsHash, - derivedDataPath, - destination: options.destination, - signing: options.signing, - }); - - if (canReuseBuild) { - xctestAgentLogger.info( - 'Reusing cached XCTest agent build for %s target', - options.destination + if (!fs.existsSync(xctestrunFilePath)) { + throw new Error( + `Missing external XCTest run file at ${xctestrunFilePath}. Check ${XCTEST_AGENT_XCTESTRUN_FILE_ENV}.` ); - xctestAgentLogger.debug( - 'reusing cached XCTest agent build for %s', - options.destination - ); - - return { - destination: options.destination, - derivedDataPath, - reused: true, - xctestrunPath: getXCTestRunPath(derivedDataPath), - }; } - } - - fs.mkdirSync(derivedDataPath, { recursive: true }); - - xctestAgentLogger.debug('building XCTest agent for %s', options.destination); - xctestAgentLogger.info( - 'Building XCTest agent for %s target', - options.destination - ); - - const signingArgs = getXCTestAgentBuildSigningArgs( - options.destination, - options.signing - ); - const provisioningArgs: string[] = []; - - if (options.destination === 'device' && options.signing?.teamId) { - provisioningArgs.push('-allowProvisioningUpdates'); - } - - const buildArgs = [ - 'build-for-testing', - '-project', - getXCTestAgentProjectFilePath(), - '-scheme', - XCTEST_AGENT_SCHEME_NAME, - '-destination', - getXCTestAgentBuildDestination(options.destination), - '-derivedDataPath', - derivedDataPath, - ...provisioningArgs, - ...signingArgs, - ]; - await spawn('xcodebuild', buildArgs, { - cwd: getXCTestAgentProjectRoot(), - signal: options.signal, - }); - - const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); - - if (!xctestrunRelativePath) { - throw new Error( - `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( - derivedDataPath - )}` - ); - } - - if (options.destination === 'simulator') { - if (!simulatorCacheContext) { - throw new Error('Missing simulator cache context for XCTest agent build'); - } - - writeSimulatorBuildManifest(derivedDataPath, { - artifactName: XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT, - destinationKind: 'simulator', - schemaVersion: XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION, - xctestrunRelativePath, - ...simulatorCacheContext, - }); - } else { - writeBuildManifest(derivedDataPath, { - buildInputsHash, - destinationKind: options.destination, - signing: options.signing, - }); - } - - xctestAgentLogger.info( - 'Built XCTest agent for %s target', - options.destination - ); - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - return { - destination: options.destination, - derivedDataPath, - reused: false, - xctestrunPath: path.join(buildProductsPath, xctestrunRelativePath), - }; -}; + return { + launch: { + derivedDataPath: + getEnvironmentPath(XCTEST_AGENT_DERIVED_DATA_PATH_ENV) ?? + getXCTestAgentDerivedDataPath(context.destination, context.projectRoot), + kind: 'xctestrun', + xctestrunPath: xctestrunFilePath, + }, + source: 'external', + }; + }, +}); + +/** + * Artifact sources in the order they are trusted: an explicitly supplied + * run file, then artifacts shipped with the package, then a local build. + * + * Simulator and device artifacts need separate providers. A simulator build is + * runnable as shipped; a device build is shipped unsigned and has to acquire + * the consumer's signature first. + */ +export const createDefaultXCTestAgentProviders = (): XCTestAgentProvider[] => [ + createExternalXCTestAgentProvider(), + createPrebuiltXCTestAgentProvider(), + createResignedDeviceXCTestAgentProvider(), + createBuildXCTestAgentProvider(), +]; const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { return { @@ -764,7 +181,10 @@ const delay = async (ms: number, signal?: AbortSignal) => { } }; -const waitForAbortable = async (promise: Promise, signal?: AbortSignal): Promise => { +const waitForAbortable = async ( + promise: Promise, + signal?: AbortSignal +): Promise => { if (!signal) { return await promise; } @@ -902,6 +322,37 @@ const attachProcessOutputLog = async (options: { void Promise.allSettled([pipeTask, forwardTask]); }; +const getXCTestAgentRunDestination = (target: XCTestAgentTarget): string => { + if (target.kind === 'simulator') { + return `platform=iOS Simulator,id=${target.id}`; + } + + return `platform=iOS,id=${target.id}`; +}; + +const getXCTestAgentLaunchArgs = ( + target: XCTestAgentTarget, + launch: XCTestAgentLaunchInput +): string[] => { + const runInputArgs = + launch.kind === 'xctestrun' + ? ['-xctestrun', launch.xctestrunPath] + : ['-project', launch.projectFilePath, '-scheme', launch.scheme]; + + return [ + 'test-without-building', + ...runInputArgs, + '-destination', + getXCTestAgentRunDestination(target), + '-parallel-testing-enabled', + 'NO', + '-maximum-parallel-testing-workers', + '1', + '-derivedDataPath', + launch.derivedDataPath, + ]; +}; + export const createXCTestAgentController = (options: { appBundleId?: string; target: XCTestAgentTarget; @@ -910,9 +361,13 @@ export const createXCTestAgentController = (options: { startupTimeoutMs?: number; /** Maximum time for the XCTest client to close gracefully. */ shutdownTimeoutMs?: number; + /** Artifact providers, consulted in order. Defaults to external then build. */ + providers?: XCTestAgentProvider[]; + projectRoot?: string; }): XCTestAgentController => { const { target } = options; const capabilities = options.capabilities ?? []; + const providers = options.providers ?? createDefaultXCTestAgentProviders(); const startupTimeoutMs = options.startupTimeoutMs ?? XCTEST_AGENT_STARTUP_TIMEOUT_MS; const logArtifacts = createHarnessArtifactDirectory({ @@ -925,8 +380,7 @@ export const createXCTestAgentController = (options: { logArtifacts.directoryPath, 'xcodebuild.log' ); - let preparedDerivedDataPath = getXCTestAgentDerivedDataPath(target.kind); - let preparedXCTestRunFilePath: string | null = null; + let preparedLaunchInput: XCTestAgentLaunchInput | null = null; let prepared = false; let agentProcess: OwnedProcess | null = null; let agentClient: ReturnType | null = null; @@ -968,39 +422,23 @@ export const createXCTestAgentController = (options: { return; } - const externalXCTestConfiguration = getExternalXCTestConfiguration( - preparedDerivedDataPath - ); - - if (externalXCTestConfiguration) { - preparedDerivedDataPath = externalXCTestConfiguration.derivedDataPath; - preparedXCTestRunFilePath = - externalXCTestConfiguration.xctestrunFilePath; - - prepared = true; - xctestAgentLogger.info( - 'Using external XCTest run file for %s target: %s', - target.kind, - preparedXCTestRunFilePath - ); - return; - } - - let signing: XCTestAgentBuildSigning | undefined; - - if (target.kind === 'device') { - signing = target.codeSign; - } - - const buildResult = await buildXCTestAgent({ + const signing = target.kind === 'device' ? target.codeSign : undefined; + const artifacts = await resolveXCTestAgentArtifacts(providers, { + deviceId: target.kind === 'device' ? target.id : undefined, destination: target.kind, + projectRoot: options.projectRoot ?? process.cwd(), signing, signal, }); - preparedDerivedDataPath = buildResult.derivedDataPath; - preparedXCTestRunFilePath = null; + preparedLaunchInput = artifacts.launch; prepared = true; + + xctestAgentLogger.info( + 'Prepared XCTest agent artifacts for %s target (%s)', + target.kind, + artifacts.source + ); }; const ensureStarted = async (signal?: AbortSignal) => { @@ -1010,6 +448,14 @@ export const createXCTestAgentController = (options: { return; } + const launch = preparedLaunchInput; + + if (!launch) { + throw new Error( + 'XCTest agent artifacts are missing after preparation. This is a Harness bug.' + ); + } + const port = options.port ?? (await getAvailablePort()); const runtimeConfiguration = getRuntimeConfiguration(capabilities); @@ -1019,27 +465,8 @@ export const createXCTestAgentController = (options: { target.kind ); xctestAgentLogger.debug('Using XCTest agent port %d', port); - const xcodebuildRunInputArgs = preparedXCTestRunFilePath - ? ['-xctestrun', preparedXCTestRunFilePath] - : [ - '-project', - getXCTestAgentProjectFilePath(), - '-scheme', - XCTEST_AGENT_SCHEME_NAME, - ]; - - const xcodebuildArgs = [ - 'test-without-building', - ...xcodebuildRunInputArgs, - '-destination', - getXCTestAgentRunDestination(target), - '-parallel-testing-enabled', - 'NO', - '-maximum-parallel-testing-workers', - '1', - '-derivedDataPath', - preparedDerivedDataPath, - ]; + + const xcodebuildArgs = getXCTestAgentLaunchArgs(target, launch); agentProcess = spawnOwnedProcess('xcodebuild', xcodebuildArgs, { cwd: getXCTestAgentProjectRoot(), env: { diff --git a/website/src/docs/guides/permissions.mdx b/website/src/docs/guides/permissions.mdx index e27c8fee..d23abd43 100644 --- a/website/src/docs/guides/permissions.mdx +++ b/website/src/docs/guides/permissions.mdx @@ -71,6 +71,44 @@ This can have a negative impact on: The impact is most important to keep in mind for iOS physical devices, where the XCTest agent requires code signing and extra setup, but the same helper is also started on iOS simulators when permissions are enabled. +## Where the XCTest agent comes from + +Harness needs a built XCTest agent before it can start one. It obtains one from the first source that applies: + +1. **An `.xctestrun` you provide.** Set `HARNESS_IOS_XCTESTRUN_FILE` (optionally with `HARNESS_IOS_XCTEST_DERIVED_DATA_PATH`) to a ready `.xctestrun`, and Harness uses it as-is. +2. **Artifacts shipped with the platform package.** Releases carry a prebuilt unsigned agent for simulators and one for physical devices. Harness copies one into `.harness/cache` and runs it, so no compilation happens. A simulator copy runs as copied; a device copy is signed first, as described below. +3. **A local build.** Harness runs `xcodebuild build-for-testing` and caches the result. + +The shipped artifacts work from Xcode 26 up to the major Xcode version the release was packed with. On any other Xcode, or when the agent sources in your installed package were modified, Harness says so and builds locally instead, so a new Xcode costs one local build until a release ships artifacts packed with it. Set `HARNESS_IOS_XCTEST_PREBUILT_ROOT` to point Harness at artifacts packed elsewhere, and `HARNESS_SKIP_XCTEST_PREBUILDS=1` to skip them entirely. + +### Running on a physical device from shipped artifacts + +A device build cannot ship ready to run, because a runnable bundle carries your certificate and a profile that lists your device. So Harness ships device artifacts unsigned and signs a private copy on your machine, in the same `.harness/cache` location, using the certificate and profile you configure. This needs no network access and takes about a second. + +Harness picks the certificate by matching the certificates embedded in your provisioning profile against the usable identities in your keychain. It picks the profile by requiring that it covers the agent's bundle ids, lists your device, and has not expired. When no installed profile qualifies, Harness builds locally instead, which is the path that can reach the Developer Portal to obtain one. + +Two settings matter when signing is not automatic: + +- `signingIdentity` narrows the choice to one certificate. `codesign` refuses the name `Apple Development` outright once your keychain holds two development certificates, so name the certificate by its SHA-1 if you have several accounts. +- `bundleIdPrefix` rewrites the agent's own bundle ids. Individual developer accounts and many corporate ones cannot mint a profile covering `com.callstackincubator.*`, and a prefix that your wildcard profile does cover is the way around that. It must be a reverse-DNS prefix such as `com.example.myapp`. It rewrites the three bundle Info.plists along with the bundle ids the `.xctestrun` names, so the test session launches what was installed. It applies to the artifact that ships with the package: a locally built agent keeps the shipped ids, because `xcodebuild` cannot override one target's bundle id without giving both agent targets the same one. + +```javascript +applePlatform({ + name: 'iphone', + device: applePhysicalDevice('My iPhone', { + codeSign: { + teamId: 'TEAMID1234', + bundleIdPrefix: 'com.example.myapp', + signingIdentity: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2', + provisioningProfile: 'My Wildcard Profile', + }, + }), + bundleId: 'com.example.myapp', +}) +``` + +Device artifacts ship unsigned and generic, so they carry nothing specific to your account. They add roughly half again to the package download. Pack a narrower set with `HARNESS_XCTEST_PREBUILD_DESTINATIONS=simulator`, or point Harness at artifacts packed elsewhere with `HARNESS_IOS_XCTEST_PREBUILT_ROOT`. + ## Physical iOS Devices To use permission automation on a physical iOS device, the runner must provide `device.codeSign`.