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
16 changes: 14 additions & 2 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface BuildRunOptions {
variant?: string;
abi?: string;
allArch?: boolean;
arch?: string;
configuration?: string;
scheme?: string;
buildConfiguration?: string;
Expand Down Expand Up @@ -165,17 +166,28 @@ function buildTarget(options: BuildRunOptions): string {
return `on-${slug(name)}`;
}

/** The simulator slice a local build compiles for this machine: `arm64` or `x86_64`. */
export function hostSimulatorArch(arch: string = process.arch): 'arm64' | 'x86_64' {
return arch === 'x64' ? 'x86_64' : 'arm64';
}

export function buildCacheKey(platform: string, fingerprintHash: string, options: unknown = {}): string {
const opts = (options && typeof options === 'object' ? options : {}) as BuildRunOptions;
const abi = platform === 'android' && typeof opts.abi === 'string' ? slug(opts.abi) : '';
const target = buildTarget(opts);
const abi =
platform === 'android' && typeof opts.abi === 'string'
? slug(opts.abi)
: platform === 'ios' && target === 'sim' && typeof opts.arch === 'string'
? slug(opts.arch)
: '';
const allArch = platform === 'android' && opts.allArch === true ? '-all-arch' : '';
const compiler = typeof opts.compiler === 'string' ? slug(opts.compiler) : '';
const profile = typeof opts.buildProfile === 'string' ? slug(opts.buildProfile) : '';
const scheme =
platform === 'ios' && typeof opts.scheme === 'string' && opts.scheme
? createHash('sha256').update(opts.scheme).digest('hex')
: '';
return `${fingerprintHash}-${buildVariant(platform, opts)}-${buildTarget(opts)}${abi ? `-${abi}` : ''}${allArch}${compiler ? `-${compiler}` : ''}${profile ? `-${profile}` : ''}${scheme ? `-scheme-${scheme}` : ''}`;
return `${fingerprintHash}-${buildVariant(platform, opts)}-${target}${abi ? `-${abi}` : ''}${allArch}${compiler ? `-${compiler}` : ''}${profile ? `-${profile}` : ''}${scheme ? `-scheme-${scheme}` : ''}`;
}

export interface RegisterOptions {
Expand Down
5 changes: 5 additions & 0 deletions packages/expo-build-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ per-ABI builds `stim android` makes by default; a universal debug build that
`stim android` makes with `optimizations.android.targetAbiOnly: false` is not
shared with Expo runs.

An iOS Debug simulator build compiles only this Mac's architecture, so the
provider keys it on `arm64` or `x86_64`, matching `stim ios`. Other iOS
configurations and `--device generic` keep the project's architectures and
their unsuffixed key.

The provider works without the `stim` npm package. When Stim is available,
the provider registers its cache so `stim gc` can report and trim it.

Expand Down
16 changes: 13 additions & 3 deletions packages/expo-build-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
artifactIn,
buildCacheRoot,
buildCacheKey,
hostSimulatorArch,
registerCache,
resolveArtifact,
storeArtifact,
Expand Down Expand Up @@ -74,10 +75,19 @@ function connectedDeviceAbi(): string | null {
}
}

// Expo `run:android` builds a `debug` or `debugOptimized` build type without `--all-arch`
// only for the ABI of the device it selected, and boots that device before asking the
// provider for a build.
function keyOptions(platform: string, runOptions: RunOptions = {}): RunOptions | null {
// Expo `run:ios` targets the booted simulator, so Xcode compiles a Debug build for this Mac's
// architecture only; other configurations and the generic destination keep the project's ARCHS.
// `--device <name>` keys as on-<name> with no arch: Expo's name cannot tell a simulator from a phone.
if (platform === 'ios') {
const configuration = runOptions.configuration ?? runOptions.buildConfiguration;
if (runOptions.arch || runOptions.device === 'generic') return runOptions;
if (configuration && configuration.trim().toLowerCase() !== 'debug') return runOptions;
return { ...runOptions, arch: hostSimulatorArch() };
}
// Expo `run:android` builds a `debug` or `debugOptimized` build type without `--all-arch`
// only for the ABI of the device it selected, and boots that device before asking the
// provider for a build.
if (platform !== 'android' || runOptions.abi || runOptions.allArch) return runOptions;
const parts = (runOptions.variant || 'debug').split(/(?=[A-Z])/);
let buildType = parts.pop()!;
Expand Down
63 changes: 63 additions & 0 deletions packages/stim-cli/src/__tests__/agent-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
isLoopbackDaemon,
metroHintFrom,
openArgs,
parseHealthHostArch,
readRemoteSimulatorArch,
remoteProfile,
remoteProfilePath,
sessionNameFor,
Expand Down Expand Up @@ -185,3 +187,64 @@ describe('closeArgs', () => {
expect(args[args.indexOf('--remote-config') + 1]).toBe('/w/.stim/agent-device.remote.json');
});
});

describe('the remote simulator arch', () => {
test.each([
['a daemon reporting its host', { ok: true, hostArch: 'x86_64' }, 'x86_64'],
[
'a proxy, whose upstream names the daemon machine',
{ hostArch: 'arm64', upstream: { hostArch: 'x86_64' } },
'x86_64',
],
['a proxy whose upstream predates hostArch', { hostArch: 'x86_64', upstream: { ok: true } }, 'x86_64'],
['a daemon without hostArch', { ok: true, service: 'agent-device-daemon' }, null],
['an arch Stim cannot build', { hostArch: 'riscv64' }, null],
['a non-object body', 'arm64', null],
])('%s', (_name, body, expected) => {
expect(parseHealthHostArch(body)).toBe(expected);
});

function respond(body: unknown, ok = true): typeof fetch {
return (async () => ({ ok, json: async () => body })) as unknown as typeof fetch;
}

test('reads /health under the daemon base URL', async () => {
const urls: string[] = [];
const fetchImpl = (async (url: string) => {
urls.push(url);
return { ok: true, json: async () => ({ hostArch: 'x86_64' }) };
}) as unknown as typeof fetch;
expect(
await readRemoteSimulatorArch({ baseUrl: 'https://proxy.example/agent-device/' }, { fetch: fetchImpl }),
).toBe('x86_64');
expect(urls).toEqual(['https://proxy.example/agent-device/health']);
});

test.each([
['no daemon', null, respond({ hostArch: 'x86_64' })],
['an absent field', { baseUrl: 'https://d' }, respond({ ok: true })],
['an error status', { baseUrl: 'https://d' }, respond({ hostArch: 'x86_64' }, false)],
[
'a refused connection',
{ baseUrl: 'https://d' },
(async () => Promise.reject(new TypeError('fetch failed'))) as typeof fetch,
],
[
'a body that is not JSON',
'https://d',
(async () => ({ ok: true, json: async () => Promise.reject(new SyntaxError('x')) })) as unknown as typeof fetch,
],
] as [string, { baseUrl: string } | null, typeof fetch][])('%s means arm64', async (_name, baseUrl, fetchImpl) => {
expect(await readRemoteSimulatorArch(baseUrl, { fetch: fetchImpl })).toBe('arm64');
});

test('a daemon that does not answer in time means arm64', async () => {
const hanging = ((_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
})) as unknown as typeof fetch;
const started = Date.now();
expect(await readRemoteSimulatorArch({ baseUrl: 'https://d' }, { fetch: hanging, timeoutMs: 20 })).toBe('arm64');
expect(Date.now() - started).toBeLessThan(5_000);
});
});
10 changes: 10 additions & 0 deletions packages/stim-cli/src/__tests__/build-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,15 @@ test('android cache keys separate target ABIs while preserving the universal key
expect(buildCacheKey('ios', hash, { abi: 'arm64-v8a' })).toBe(`${hash}-debug-sim`);
});

test('ios simulator keys separate single-arch builds while a universal build keeps its key', () => {
const hash = 'abc123';
expect(buildCacheKey('ios', hash, { isSimulator: true, arch: 'arm64' })).toBe(`${hash}-debug-sim-arm64`);
expect(buildCacheKey('ios', hash, { isSimulator: true, arch: 'x86_64' })).toBe(`${hash}-debug-sim-x86-64`);
expect(buildCacheKey('ios', hash, { configuration: 'Release', isSimulator: true })).toBe(`${hash}-release-sim`);
expect(buildCacheKey('ios', hash, { isSimulator: false, arch: 'arm64' })).toBe(`${hash}-debug-device`);
expect(buildCacheKey('android', hash, { arch: 'arm64' })).toBe(`${hash}-debug-sim`);
});

test('the CLI and the Expo provider compute the same key', () => {
for (const [platform, options] of [
['ios', {}],
Expand All @@ -264,6 +273,7 @@ test('the CLI and the Expo provider compute the same key', () => {
['ios', { device: 'generic' }],
['ios', { device: 'Janic iPhone' }],
['ios', { device: true }],
['ios', { arch: 'x86_64', scheme: 'App' }],
['android', { variant: 'release', device: 'emulator-5554', abi: 'arm64-v8a' }],
['android', { variant: 'release', compiler: 'apple-cas-012345' }],
] as [string, Record<string, unknown>][]) {
Expand Down
42 changes: 40 additions & 2 deletions packages/stim-cli/src/__tests__/cache-packages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, wri
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert';
import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core';
import { METRO_NAMED_CACHE_LAYOUT, hostSimulatorArch } from '@stim-cli/core';
import { readManifest } from '../cache/cache-manifest.ts';
import { sharedBuildCache, sharedMetroCache } from '../workspace/paths.ts';
import { hasStoreAt } from '../supervisor/metro-store.ts';
Expand Down Expand Up @@ -30,7 +30,7 @@ test.each(['ios', 'android'])(
if (platform === 'ios') mkdirSync(build);
const binary = platform === 'ios' ? join(build, 'binary') : build;
writeFileSync(binary, 'provider bytes');
const runOptions = platform === 'android' ? { abi: 'arm64-v8a' } : {};
const runOptions = platform === 'android' ? { abi: 'arm64-v8a' } : { arch: 'arm64' };

const providerArtifact = await provider.uploadBuildCache({
platform,
Expand Down Expand Up @@ -189,6 +189,44 @@ test('the standalone Expo build cache provider separates Android ABIs', async ()
}
});

test('the standalone Expo build cache provider keys iOS Debug simulator builds by this Mac arch', async () => {
const home = mkdtempSync(join(tmpdir(), 'stim-pkg-home-'));
const cacheRoot = mkdtempSync(join(tmpdir(), 'stim-pkg-bc-'));
process.env.STIM_HOME = home;
process.env.STIM_BUILD_CACHE = cacheRoot;
try {
const provider = await import('@stim-cli/expo-build-cache');
const upload = async (name: string, runOptions: Record<string, unknown>) => {
const app = join(home, `${name}.app`);
mkdirSync(app);
writeFileSync(join(app, 'identity'), name);
await provider.uploadBuildCache({ platform: 'ios', fingerprintHash: 'fp', buildPath: app, runOptions });
};
const identity = async (runOptions: Record<string, unknown>) => {
const app = await provider.resolveBuildCache({ platform: 'ios', fingerprintHash: 'fp', runOptions });
return app ? readFileSync(join(app, 'identity'), 'utf8') : null;
};
const other = hostSimulatorArch() === 'arm64' ? 'x86_64' : 'arm64';
await upload('host', { configuration: 'Debug' });
await upload('release', { configuration: 'Release' });
await upload('generic', { device: 'generic' });

expect(await identity({ arch: hostSimulatorArch() })).toBe('host');
expect(await identity({})).toBe('host');
expect(await identity({ arch: other })).toBeNull();
expect(await identity({ configuration: 'Release' })).toBe('release');
expect(await identity({ device: 'generic' })).toBe('generic');
expect(resolveBuild('ios', buildCacheKey('ios', 'fp', { isSimulator: true, arch: hostSimulatorArch() }))).toBe(
await provider.resolveBuildCache({ platform: 'ios', fingerprintHash: 'fp', runOptions: {} }),
);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cacheRoot, { recursive: true, force: true });
delete process.env.STIM_HOME;
delete process.env.STIM_BUILD_CACHE;
}
});

test('the Metro cache store registers itself on this Node, at the shard depth', async () => {
const home = mkdtempSync(join(tmpdir(), 'stim-pkg-home2-'));
const cacheRoot = join(tmpdir(), `stim-pkg-metro-${process.pid}`);
Expand Down
21 changes: 21 additions & 0 deletions packages/stim-cli/src/__tests__/engine-xcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,27 @@ describe('buildIos with a mocked executor', () => {
await promise;
});

test.each([
['a generic destination with an arch builds that one slice', 'arm64', ['ARCHS=arm64', 'ONLY_ACTIVE_ARCH=YES']],
['a generic destination without an arch leaves ARCHS to the project', null, []],
])('%s', async (_name, arch, expected) => {
const child = fakeChild();
const spawnCalls = harness(tmp, { child });
const promise = buildIos({
root: tmp,
destination: 'generic/platform=iOS Simulator',
arch,
logWriter: recordingWriter(),
compilationCache: ['COMPILATION_CACHE_ENABLE_CACHING=YES'],
});
const args = spawnCalls[0]?.args ?? [];
expect(args[args.indexOf('-destination') + 1]).toBe('generic/platform=iOS Simulator');
expect(args.slice(args.indexOf('build') + 1)).toEqual(['COMPILATION_CACHE_ENABLE_CACHING=YES', ...expected]);
makeProduct(workspaceDerivedData(tmp));
child.emit('close', 0, null);
await promise;
});

test('compilationCache: null is how a caller turns it off entirely', async () => {
const child = fakeChild();
const spawnCalls = harness(tmp, { child });
Expand Down
Loading
Loading