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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*

Expand Down
7 changes: 7 additions & 0 deletions .nx/version-plans/version-plan-1792238400000.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/platform-ios/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
173 changes: 173 additions & 0 deletions packages/platform-ios/scripts/pack-xctest-agent-artifacts.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
}
41 changes: 41 additions & 0 deletions packages/platform-ios/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
105 changes: 105 additions & 0 deletions packages/platform-ios/src/__tests__/xctest-agent-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading