mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f560f8fc2 | ||
|
|
a3e4617d1c | ||
|
|
2e45595250 | ||
|
|
0c768729a9 | ||
|
|
f37ad5e137 | ||
|
|
4d3cba95af | ||
|
|
d57eadf887 | ||
|
|
ccba9a0c8d | ||
|
|
3fc8dadd63 | ||
|
|
50dcb89a0c | ||
|
|
84b3bc6354 | ||
|
|
2749518a31 |
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
|
||||
- [simulator-launcher] Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
|
||||
- [build-tools] Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [vm-tools] Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
"STAGING": "27.5.0",
|
||||
"PRODUCTION": "27.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.5.0",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/downloader": "27.6.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/template-file": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/template-file": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -821,6 +821,17 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the resolved version to agent-device after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { package_version: 'latest' }, undefined, ctx, true);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ packageVersion: '999.1.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts only one application source', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
|
||||
|
||||
+498
-4
@@ -1,7 +1,10 @@
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import plist from 'plist';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as timersPromises from 'node:timers/promises';
|
||||
|
||||
@@ -9,6 +12,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { PackageManager, resolveConfiguredPackageManager } from '../../../utils/packageManager';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -98,6 +102,46 @@ jest.mock('../../utils/localEgressSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
const preparedHome = path.join(os.homedir(), 'Library/Caches/expo-simulator-startup/appium');
|
||||
const preparedManifest = path.join(path.dirname(preparedHome), 'prepared.json');
|
||||
const preparedBin = path.join(preparedHome, 'node_modules', '.bin', 'appium');
|
||||
const preparedRunner = path.join(
|
||||
preparedHome,
|
||||
'preinstalled-wda',
|
||||
'WebDriverAgentRunner-Runner.app'
|
||||
);
|
||||
|
||||
const driverList = ['driver', 'list', '--installed', '--json'];
|
||||
|
||||
/** Today's `driver list` output: the prepared home lists xcuitest, a new home lists nothing. */
|
||||
function driverListStdout(command: string): string {
|
||||
return JSON.stringify(command === preparedBin ? { xcuitest: { installed: true } } : {});
|
||||
}
|
||||
|
||||
/** Writes the Appium home that `simulator-launcher prefetch` prepares for reuse. */
|
||||
function writePreparedHome(): void {
|
||||
fs.mkdirSync(path.dirname(preparedBin), { recursive: true });
|
||||
fs.writeFileSync(preparedBin, '');
|
||||
writeInstalledVersion('appium', '3.9.0');
|
||||
writeInstalledVersion('appium-xcuitest-driver', '12.22.8');
|
||||
fs.mkdirSync(preparedRunner, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: 'Runner' })
|
||||
);
|
||||
fs.writeFileSync(path.join(preparedRunner, 'Runner'), 'executable', { mode: 0o755 });
|
||||
writePreparedManifest({ version: '3.9.0', xcuitest: '12.22.8', home: preparedHome });
|
||||
}
|
||||
|
||||
function writePreparedManifest(appium: unknown): void {
|
||||
fs.writeFileSync(preparedManifest, `${JSON.stringify({ tools: { appium } })}\n`);
|
||||
}
|
||||
|
||||
function writeInstalledVersion(name: string, version: string): void {
|
||||
const directory = path.join(preparedHome, 'node_modules', name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version }));
|
||||
}
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
@@ -172,9 +216,9 @@ describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
return { stdout: driverListStdout(command) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
@@ -208,6 +252,255 @@ describe(installAppiumAsync, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the xcuitest home prefetched without installing', async () => {
|
||||
writePreparedHome();
|
||||
const info = jest.fn();
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger: { info } as never,
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.calls).toEqual([
|
||||
[
|
||||
preparedBin,
|
||||
driverList,
|
||||
{ env: { EXISTING: 'value', APPIUM_HOME: preparedHome }, stdio: 'pipe' },
|
||||
],
|
||||
]);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
'Using the prefetched Appium home (appium 3.9.0, xcuitest 12.22.8).'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appiumHome: preparedHome,
|
||||
appiumBinPath: preparedBin,
|
||||
appiumEnv: { EXISTING: 'value', APPIUM_HOME: preparedHome },
|
||||
prepared: true,
|
||||
});
|
||||
|
||||
const env = { EAS_OVERRIDE_PACKAGE_MANAGER: 'nope' };
|
||||
const expected = (() => {
|
||||
try {
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
})();
|
||||
const onError = jest.fn();
|
||||
await expect(
|
||||
installAppiumAsync({ versionSpec: '^3', driverName: 'xcuitest', env, logger, onError })
|
||||
).rejects.toEqual(expected);
|
||||
expect(onError).toHaveBeenCalledWith(expected);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([, args]) => args)).toEqual([driverList, driverList]);
|
||||
});
|
||||
|
||||
it('reuses WDA with binary bundle metadata', async () => {
|
||||
writePreparedHome();
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
Buffer.from(
|
||||
'YnBsaXN0MDDRAQJfEBJDRkJ1bmRsZUV4ZWN1dGFibGVWUnVubmVyCAsgAAAAAAAAAQEAAAAAAAAAAwAAAAAAAAAAAAAAAAAAACc=',
|
||||
'base64'
|
||||
)
|
||||
);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
expect(result.prepared).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a version spec it does not satisfy', { versionSpec: '3.5.0' }, () => {}],
|
||||
['the uiautomator2 driver', { driverName: 'uiautomator2' as const }, () => {}],
|
||||
['no prepared.json', {}, () => fs.rmSync(preparedManifest)],
|
||||
['an invalid prepared.json', {}, () => fs.writeFileSync(preparedManifest, 'not json')],
|
||||
['a prepared.json without xcuitest', {}, () => writePreparedManifest({ version: '3.9.0' })],
|
||||
[
|
||||
'an older per-tool manifest',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedManifest);
|
||||
fs.writeFileSync(
|
||||
path.join(preparedHome, 'prepared.json'),
|
||||
JSON.stringify({
|
||||
appium: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: preparedHome,
|
||||
})
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'a home prepared at another path',
|
||||
{},
|
||||
() =>
|
||||
writePreparedManifest({
|
||||
version: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: '/Users/other/appium',
|
||||
}),
|
||||
],
|
||||
['another installed Appium', {}, () => writeInstalledVersion('appium', '2.19.0')],
|
||||
[
|
||||
'another installed xcuitest driver',
|
||||
{},
|
||||
() => writeInstalledVersion('appium-xcuitest-driver', '12.0.0'),
|
||||
],
|
||||
[
|
||||
'no xcuitest driver',
|
||||
{},
|
||||
() =>
|
||||
fs.rmSync(path.join(preparedHome, 'node_modules', 'appium-xcuitest-driver'), {
|
||||
recursive: true,
|
||||
}),
|
||||
],
|
||||
['no runner', {}, () => fs.rmSync(preparedRunner, { recursive: true })],
|
||||
['no runner metadata', {}, () => fs.rmSync(path.join(preparedRunner, 'Info.plist'))],
|
||||
[
|
||||
'invalid runner metadata',
|
||||
{},
|
||||
() => fs.writeFileSync(path.join(preparedRunner, 'Info.plist'), 'invalid'),
|
||||
],
|
||||
['no runner executable', {}, () => fs.rmSync(path.join(preparedRunner, 'Runner'))],
|
||||
[
|
||||
'a non-executable runner binary',
|
||||
{},
|
||||
() => fs.chmodSync(path.join(preparedRunner, 'Runner'), 0o644),
|
||||
],
|
||||
['an empty runner binary', {}, () => fs.writeFileSync(path.join(preparedRunner, 'Runner'), '')],
|
||||
[
|
||||
'a directory as runner binary',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(path.join(preparedRunner, 'Runner'));
|
||||
fs.mkdirSync(path.join(preparedRunner, 'Runner'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'an executable outside the bundle',
|
||||
{},
|
||||
() =>
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: '../outside' })
|
||||
),
|
||||
],
|
||||
['an unreadable runner', {}, () => fs.chmodSync(preparedRunner, 0o000)],
|
||||
[
|
||||
'a runner that is a file',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedRunner, { recursive: true });
|
||||
// Readable and executable, so only the directory check rejects it.
|
||||
fs.writeFileSync(preparedRunner, '', { mode: 0o755 });
|
||||
},
|
||||
],
|
||||
])('installs as before for %s', async (_name, options, breakHome) => {
|
||||
writePreparedHome();
|
||||
breakHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const versionSpec = 'versionSpec' in options ? options.versionSpec : '^3';
|
||||
const resolveRequestedVersionAsync = jest.fn(async () => {
|
||||
throw new Error('registry unavailable');
|
||||
});
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: 'driverName' in options ? options.driverName : 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', `appium@${versionSpec}`],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
expect(path.basename(result.appiumHome)).toMatch(/^eas-appium-home-/);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(resolveRequestedVersionAsync).not.toHaveBeenCalled();
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a driver list without xcuitest', async () => ({ stdout: '{}' })],
|
||||
['a failed driver list', async () => Promise.reject(new Error('appium exited with 1.'))],
|
||||
])('installs as before after %s in the prepared home', async (_name, list) => {
|
||||
writePreparedHome();
|
||||
jest.mocked(spawn).mockImplementationOnce(list as never);
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawn).mock.calls.slice(0, 2)).toEqual([
|
||||
[preparedBin, driverList, expect.objectContaining({ stdio: 'pipe' })],
|
||||
[
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({ cwd: result.appiumHome }),
|
||||
],
|
||||
]);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('kills the prepared-home driver list on a stop and installs nothing', async () => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('session stopped');
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
let finishList!: (error: Error) => void;
|
||||
let listStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
listStarted = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
listStarted();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
finishList = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await started;
|
||||
controller.abort(failure);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
finishList(new Error('killed'));
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
finishList?.(new Error('test cleanup'));
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
@@ -302,6 +595,115 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(
|
||||
[BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX].flatMap(platform =>
|
||||
['^3', '3.x'].map(version => [platform, version] as const)
|
||||
)
|
||||
)(
|
||||
'starts cold Appium on %s with cached installer range %s and no registry lookup',
|
||||
async (runtimePlatform, packageVersion) => {
|
||||
// Android must ignore prepared XCUITest files too.
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
writePreparedHome();
|
||||
}
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'view') {
|
||||
throw new Error('registry unavailable');
|
||||
}
|
||||
return { stdout: '{}' };
|
||||
}) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: packageVersion },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[0] === 'view')).toBe(false);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', `appium@${packageVersion}`],
|
||||
expect.anything()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['latest', '^3', '3.x'].flatMap(requested =>
|
||||
['3.9.0', '3.10.0'].map(latest => [requested, latest])
|
||||
)
|
||||
)('checks %s before using the prepared Appium version (%s)', async (requested, latest) => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout:
|
||||
args[0] === 'view'
|
||||
? JSON.stringify(latest)
|
||||
: args.includes('--json')
|
||||
? driverListStdout(command)
|
||||
: '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: requested },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
const installs = jest.mocked(spawn).mock.calls.filter(([, args]) => args[0] === 'install');
|
||||
if (latest === '3.9.0') {
|
||||
expect(installs).toHaveLength(0);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
} else {
|
||||
expect(installs).toHaveLength(1);
|
||||
expect(installs[0][1]).toContain('[email protected]');
|
||||
}
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
});
|
||||
|
||||
it('claims the warm Simulator for Appium without its package version', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
|
||||
@@ -369,8 +771,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? driverListStdout(command) : '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
@@ -625,6 +1027,73 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
return stopWarm;
|
||||
}
|
||||
|
||||
it.each(['cancelled lookup', 'AbortError lookup', 'resolved lookup'])(
|
||||
'recovers from failed warm adoption with a %s',
|
||||
async stage => {
|
||||
writePreparedHome();
|
||||
const stopWarm = mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const adoptionError = new WarmSessionAdoptionError();
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let lookups = 0;
|
||||
jest.mocked(spawn).mockImplementation((async (command, args, options) => {
|
||||
if (args[0] !== 'view') {
|
||||
return { stdout: args.includes('--json') ? driverListStdout(command) : '' };
|
||||
}
|
||||
lookups++;
|
||||
if (lookups === 1 && stage !== 'resolved lookup') {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(
|
||||
stage === 'AbortError lookup'
|
||||
? new DOMException('Aborted', 'AbortError')
|
||||
: options!.signal!.reason
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
failHost(adoptionError);
|
||||
});
|
||||
}
|
||||
return { stdout: JSON.stringify('3.9.0') };
|
||||
}) as typeof spawn);
|
||||
if (stage === 'resolved lookup') {
|
||||
const process = jest.mocked(spawnDetached).getMockImplementation()!;
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
failHost(adoptionError);
|
||||
return process(options);
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'iPhone 17' },
|
||||
package_version: { value: '^3' },
|
||||
},
|
||||
true
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(lookups).toBe(stage === 'resolved lookup' ? 1 : 2);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(stage === 'resolved lookup' ? 2 : 1);
|
||||
expect(fs.existsSync(preparedRunner)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'waits for warm %s teardown before finishing the session host',
|
||||
async stage => {
|
||||
@@ -1102,6 +1571,31 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('a Darwin session uses the prepared Appium home and keeps it', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
writePreparedHome();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appiumHome: preparedHome, prepared: true })
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([command, args]) => [command, args])).toEqual([
|
||||
[preparedBin, driverList],
|
||||
]);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(preparedManifest)).toBe(true);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
|
||||
+23
@@ -322,6 +322,29 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the resolved Argent version after a warm mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext, { warm: true }).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: 'latest' },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: expect.arrayContaining(['@swmansion/[email protected]']) })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
@@ -183,67 +183,11 @@ describe('sandbox build functions', () => {
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the legacy token and keeps the Expo token for commands without a worker token', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({ ...context, job: {} } as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'legacy-token',
|
||||
},
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'legacy-token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the sandbox ready over GraphQL without a worker token', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {},
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
|
||||
await fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: 0.0001 } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'legacy-token' },
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
missing: 'a sandbox worker token or a legacy token',
|
||||
missing: 'the sandbox worker token',
|
||||
overrides: { job: {} },
|
||||
message: 'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN.',
|
||||
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
|
||||
},
|
||||
{
|
||||
missing: 'the MCP server URL',
|
||||
|
||||
+14
@@ -224,6 +224,20 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the resolved version to cold setup after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '0.14.0'));
|
||||
await createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{ inputs: { package_version: { value: 'latest' } }, outputs: {}, env } as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ packageVersion: '0.14.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
|
||||
@@ -205,7 +205,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
logger,
|
||||
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
|
||||
});
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = inputs.package_version.value as string | undefined
|
||||
): Promise<void> => {
|
||||
const tasks = createStartupTasks(logger, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
@@ -338,7 +341,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
|
||||
@@ -14,6 +14,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -43,6 +45,8 @@ import {
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { readPreparedSimulatorToolAsync } from '../../utils/warmSimulator';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
@@ -79,6 +83,12 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
const PreparedAppiumSchema = z.object({
|
||||
version: z.string(),
|
||||
xcuitest: z.string(),
|
||||
home: z.string(),
|
||||
});
|
||||
const PackageVersionSchema = z.object({ version: z.string() });
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -127,6 +137,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
let requestedVersion: string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
@@ -149,7 +160,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
const resolveRequestedVersionAsync =
|
||||
packageVersion === undefined
|
||||
? undefined
|
||||
: async (): Promise<string> =>
|
||||
(requestedVersion ??= await resolveSimulatorPackageVersionAsync(
|
||||
'appium',
|
||||
packageVersion,
|
||||
{ env, signal: startupSignal }
|
||||
));
|
||||
// Keep cold ranges on the configured installer's path. Tags need resolution to enforce Appium 3.
|
||||
const versionSpec = resolveAppium3VersionSpec(
|
||||
packageVersion !== undefined && !semver.validRange(packageVersion)
|
||||
? await resolveRequestedVersionAsync!()
|
||||
: packageVersion
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
@@ -211,6 +236,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -231,7 +257,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
// A prepared home is shared across sessions; remove only a job-owned home after a session.
|
||||
appiumHome = installation.prepared ? undefined : installation.appiumHome;
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
@@ -243,7 +270,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumHome: installation.appiumHome,
|
||||
prepared: installation.prepared,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -418,7 +446,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
// package_version is the Appium version. The job installs it or uses the prepared
|
||||
// home; the warm claim does not depend on it.
|
||||
{
|
||||
engine: 'appium',
|
||||
requestedDevice: deviceIdentifier,
|
||||
@@ -503,6 +532,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -510,13 +540,53 @@ export async function installAppiumAsync({
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
resolveRequestedVersionAsync?: () => Promise<string>;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
}): Promise<{
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
prepared: boolean;
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
let prepared =
|
||||
driverName === 'xcuitest'
|
||||
? await readPreparedSimulatorToolAsync('appium', PreparedAppiumSchema)
|
||||
: undefined;
|
||||
if (
|
||||
prepared &&
|
||||
!(await isPreparedAppiumCompatibleAsync(prepared, { versionSpec, env, signal }))
|
||||
) {
|
||||
prepared = undefined;
|
||||
}
|
||||
if (prepared && resolveRequestedVersionAsync) {
|
||||
versionSpec = resolveAppium3VersionSpec(await resolveRequestedVersionAsync());
|
||||
if (!semver.satisfies(prepared.version, versionSpec)) {
|
||||
prepared = undefined;
|
||||
}
|
||||
}
|
||||
if (prepared) {
|
||||
try {
|
||||
// An invalid package manager override fails as it does without a prepared home.
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
`Using the prefetched Appium home (appium ${prepared.version}, xcuitest ${prepared.xcuitest}).`
|
||||
);
|
||||
return {
|
||||
appiumHome: prepared.home,
|
||||
appiumBinPath: path.join(prepared.home, 'node_modules', '.bin', 'appium'),
|
||||
appiumEnv: { ...env, APPIUM_HOME: prepared.home },
|
||||
prepared: true,
|
||||
};
|
||||
}
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
@@ -569,7 +639,7 @@ export async function installAppiumAsync({
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
@@ -580,6 +650,67 @@ export async function installAppiumAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/** A metadata hit still needs Appium-specific version, driver and runner checks before reuse. */
|
||||
async function isPreparedAppiumCompatibleAsync(
|
||||
prepared: z.infer<typeof PreparedAppiumSchema>,
|
||||
{ versionSpec, env, signal }: { versionSpec: string; env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<boolean> {
|
||||
const { home } = prepared;
|
||||
if (home !== path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const installedVersionAsync = async (name: string): Promise<string> =>
|
||||
PackageVersionSchema.parse(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(home, 'node_modules', name, 'package.json'), 'utf8')
|
||||
)
|
||||
).version;
|
||||
if (
|
||||
!semver.satisfies(prepared.version, versionSpec) ||
|
||||
(await installedVersionAsync('appium')) !== prepared.version ||
|
||||
(await installedVersionAsync('appium-xcuitest-driver')) !== prepared.xcuitest
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const runner = path.join(home, 'preinstalled-wda', 'WebDriverAgentRunner-Runner.app');
|
||||
await fs.promises.access(runner, fs.constants.R_OK | fs.constants.X_OK);
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const metadata = await fs.promises.readFile(path.join(runner, 'Info.plist'));
|
||||
const info =
|
||||
metadata.subarray(0, 8).toString('ascii') === 'bplist00'
|
||||
? bplistParser.parseBuffer(metadata)[0]
|
||||
: plist.parse(metadata.toString('utf8'));
|
||||
const executable = z
|
||||
.object({ CFBundleExecutable: z.string().min(1) })
|
||||
.parse(info).CFBundleExecutable;
|
||||
if (path.basename(executable) !== executable || executable === '.' || executable === '..') {
|
||||
return false;
|
||||
}
|
||||
const executablePath = path.join(runner, executable);
|
||||
await fs.promises.access(executablePath, fs.constants.R_OK | fs.constants.X_OK);
|
||||
const executableStat = await fs.promises.stat(executablePath);
|
||||
if (!executableStat.isFile() || executableStat.size === 0) {
|
||||
return false;
|
||||
}
|
||||
// Today's driver list shows that the Appium CLI runs from this home. It does not load the driver.
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
path.join(home, 'node_modules', '.bin', 'appium'),
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{ env: { ...env, APPIUM_HOME: home }, stdio: 'pipe' },
|
||||
signal
|
||||
);
|
||||
if (!AppiumInstalledDriversSchema.parse(JSON.parse(stdout)).xcuitest?.installed) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -137,13 +137,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
@@ -156,7 +149,17 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const versionSpec = resolvedPackageVersion ?? 'latest';
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
logger,
|
||||
});
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
|
||||
@@ -3,9 +3,8 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { getLegacySandboxConnection } from '../utils/legacySandboxApi';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
import { getSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
@@ -29,18 +28,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
const { sandboxApi, credential, commandEnv } = workerApi
|
||||
? {
|
||||
sandboxApi: workerApi,
|
||||
credential: workerApi.token,
|
||||
commandEnv: env,
|
||||
}
|
||||
: getLegacySandboxConnection({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
env,
|
||||
sandboxId: String(inputs.sandbox_id.value),
|
||||
});
|
||||
const workerApi = requireSandboxWorkerApi(ctx);
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
@@ -58,19 +46,19 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential,
|
||||
credential: workerApi.token,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
workerApi: sandboxApi,
|
||||
env,
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await sandboxApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
|
||||
|
||||
@@ -108,7 +108,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
@@ -148,7 +151,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
@@ -2193,6 +2194,58 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['0.13.3', '@expo/[email protected]'],
|
||||
[undefined, '@expo/serve-sim@latest'],
|
||||
])(
|
||||
'uses the warm serve-sim version %s for adoption and recordings',
|
||||
async (serveSimVersion, spec) => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid: 'emulator-5554', token: 'preview-token', url: 'http://127.0.0.1:3200' },
|
||||
]);
|
||||
const recording = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
|
||||
const startAsync = jest.fn(async () => ({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
}));
|
||||
const host = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
port: 3200,
|
||||
serveSimVersion,
|
||||
turnArgs: Promise.resolve([]),
|
||||
requiresPreviewRetirement: true,
|
||||
startAsync,
|
||||
stopAsync: stopServer,
|
||||
}
|
||||
);
|
||||
try {
|
||||
expect(startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'bun',
|
||||
args: expect.arrayContaining(['x', spec]),
|
||||
})
|
||||
);
|
||||
expect(recording).toHaveBeenCalledWith(spec);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
recording.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('opens the preview while a supplied server waits for TURN and its startup handshake', async () => {
|
||||
const turn = deferred<string[]>();
|
||||
const handshake = deferred<Awaited<ReturnType<typeof spawnDetached>>>();
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
|
||||
import { LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
describe(LegacySandboxApi, () => {
|
||||
let mutation: jest.Mock;
|
||||
let api: LegacySandboxApi;
|
||||
|
||||
beforeEach(() => {
|
||||
mutation = jest.fn();
|
||||
api = new LegacySandboxApi({ mutation } as unknown as Client, 'sandbox-id');
|
||||
});
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
}
|
||||
}
|
||||
|
||||
it('marks the sandbox ready and rethrows a GraphQL error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: ['Sandbox has stopped.'] });
|
||||
mockMutationResults({ data: {} }, { error });
|
||||
|
||||
await api.markReadyAsync();
|
||||
await expect(api.markReadyAsync()).rejects.toBe(error);
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an upload session for the sandbox', async () => {
|
||||
mockMutationResults({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
).resolves.toEqual({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ codes: ['VALIDATION_ERROR'], code: 'VALIDATION_ERROR' },
|
||||
{ codes: ['VALIDATION_ERROR', 'UNEXPECTED_ERROR'], code: undefined },
|
||||
])(
|
||||
'reports upload session errors with codes $codes as a worker API error with code $code',
|
||||
async ({ codes, code }) => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: codes.map((errorCode, index) => ({
|
||||
message: `Error ${index}.`,
|
||||
extensions: { errorCode },
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.message).toBe(codes.map((_, index) => `Error ${index}.`).join('\n'));
|
||||
}
|
||||
);
|
||||
|
||||
it('retries finalize after a server error and throws a failure that persists', async () => {
|
||||
const persistent = new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
});
|
||||
mockMutationResults(
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{ error: persistent }
|
||||
);
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toBe(persistent);
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a finalized artifact without a download URL', async () => {
|
||||
mockMutationResults({
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
});
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toThrow(
|
||||
`Finalized sandbox artifact ${ARTIFACT_ID} has no download URL.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,23 @@ it('downloads the driver-matched Simulator runner and retains its XCTest framewo
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the runner of a prepared Appium home without a download', async () => {
|
||||
await fs.promises.mkdir(runner, { recursive: true });
|
||||
await expect(preparePreinstalledWdaAsync({ ...options, prepared: true })).resolves.toEqual({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
});
|
||||
await expect(
|
||||
preparePreinstalledWdaAsync({
|
||||
...options,
|
||||
prepared: true,
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
})
|
||||
).resolves.toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(runner)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
|
||||
@@ -23,7 +23,6 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
@@ -66,7 +65,7 @@ describe('sandbox artifact uploads', () => {
|
||||
logger = { info: jest.fn(), error: jest.fn() };
|
||||
uploadArtifactAsync = async options =>
|
||||
await startSandboxArtifactUploadAsync({
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
...options,
|
||||
});
|
||||
@@ -390,7 +389,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
|
||||
return new SandboxArtifactUploadManager({
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -17,7 +16,7 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
const workerApi = {} as SandboxWorkerApi | LegacySandboxApi;
|
||||
const workerApi = {} as SandboxWorkerApi;
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
|
||||
@@ -50,7 +49,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
@@ -79,7 +78,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/simulatorPackageVersion');
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
jest.mock('../IosSimulatorRecordingUtils');
|
||||
jest.mock('../warmIosSession');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
@@ -63,9 +67,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
|
||||
warm.serveSimVersion = '0.13.3';
|
||||
const server = createWarmDeviceSessionServer(ctx, logger, warm);
|
||||
expect(server).toMatchObject({
|
||||
port: 3200,
|
||||
serveSimVersion: '0.13.3',
|
||||
requiresPreviewRetirement: true,
|
||||
preinstalledApp: warm.preinstalledExpoGo,
|
||||
});
|
||||
@@ -125,7 +131,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
|
||||
const run = jest.fn().mockResolvedValue('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(run).toHaveBeenCalledWith(undefined);
|
||||
expect(run).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it('runs on the claimed warm Simulator', async () => {
|
||||
@@ -133,7 +139,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
const run = jest.fn().mockResolvedValue('warm');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(warm);
|
||||
expect(run).toHaveBeenCalledWith(warm, undefined);
|
||||
});
|
||||
|
||||
it('stops warmup and runs cold after an adoption failure', async () => {
|
||||
@@ -144,7 +150,71 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
.mockResolvedValueOnce('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'uses the resolved request for claim and setup (warm: %s)',
|
||||
async reuse => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(reuse ? warm : undefined);
|
||||
const request = { ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' };
|
||||
const run = jest.fn().mockResolvedValue('done');
|
||||
await expect(runOnWarmIosSimulatorAsync(request, run)).resolves.toBe('done');
|
||||
expect(tryClaimWarmIosSessionAsync).toHaveBeenCalledWith({
|
||||
...request,
|
||||
packageVersion: '0.14.0',
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(reuse ? warm : undefined, '0.14.0');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the resolved version through an adoption failure', async () => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new WarmSessionAdoptionError())
|
||||
.mockResolvedValue('cold');
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
);
|
||||
expect(resolveSimulatorPackageVersionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls).toEqual([
|
||||
[warm, '0.14.0'],
|
||||
[undefined, '0.14.0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops warmup and fails when an explicit request cannot be resolved', async () => {
|
||||
const error = new Error('lookup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(error);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tryClaimWarmIosSessionAsync).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves lookup and cleanup failures without starting a session', async () => {
|
||||
const lookup = new Error('lookup failed');
|
||||
const cleanup = new Error('cleanup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(lookup);
|
||||
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(cleanup);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toMatchObject({ errors: [lookup, cleanup] });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run cold after a failure during the session', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runOnWarmIosSimulatorAsync } from '../warmDeviceSessionHost';
|
||||
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
@@ -84,6 +85,24 @@ it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
|
||||
expect(session!.preinstalledExpoGo).toEqual(expoGo);
|
||||
});
|
||||
|
||||
it('keeps the exact serve-sim version from the launcher claim', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: '0.13.3' }));
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
expect(session?.serveSimVersion).toBe('0.13.3');
|
||||
});
|
||||
|
||||
it('stops warmup when the claim contains an invalid serve-sim version', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: 'latest' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the agent-device server after tool preparation', async () => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
@@ -121,7 +140,6 @@ it.each([
|
||||
{},
|
||||
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
|
||||
],
|
||||
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
|
||||
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
|
||||
[
|
||||
'a local egress session',
|
||||
@@ -149,3 +167,69 @@ it('stops the claimed session once', async () => {
|
||||
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['serve-sim', 'serveSimVersion'],
|
||||
['agent-device', 'agentDeviceVersion'],
|
||||
['argent', 'argentVersion'],
|
||||
] as const)(
|
||||
'reuses %s only when its recorded version matches the resolved request',
|
||||
async (engine, field) => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, [field]: '1.2.3' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeDefined();
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.4', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['serve-sim', 'agent-device', 'argent'] as const)(
|
||||
'uses normal setup for %s when older claims omit its version',
|
||||
async engine => {
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a resolved prerelease pin for matching warm adoption', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, serveSimVersion: '0.13.4-beta.1+build.2' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({
|
||||
engine: 'serve-sim',
|
||||
packageVersion: '0.13.4-beta.1+build.2',
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toMatchObject({ serveSimVersion: '0.13.4-beta.1+build.2' });
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves exact build metadata through job resolution and warm claim', async () => {
|
||||
const version = '0.13.4-beta.1+build.2';
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: version }));
|
||||
const run = jest.fn(async (session, resolvedVersion) => {
|
||||
expect(session?.serveSimVersion).toBe(version);
|
||||
expect(resolvedVersion).toBe(version);
|
||||
return 'warm';
|
||||
});
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ engine: 'serve-sim', packageVersion: `v${version}`, env, logger },
|
||||
run
|
||||
)
|
||||
).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -432,6 +432,8 @@ export type DeviceSessionServerLaunch = {
|
||||
*/
|
||||
export type DeviceSessionServer = {
|
||||
port: number;
|
||||
/** The exact serve-sim version prepared by the warm launcher. */
|
||||
serveSimVersion?: string;
|
||||
turnArgs: Promise<string[]>;
|
||||
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
|
||||
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
|
||||
@@ -584,6 +586,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
|
||||
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
|
||||
const port = server.port;
|
||||
packageVersion = server.serveSimVersion ?? packageVersion;
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { type Env, SystemError } from '@expo/eas-build-job';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { type SandboxArtifactUploadSession } from './sandboxWorkerApi';
|
||||
import { WorkerApiError, type WorkerApiRequestOptions } from './workerApi';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
$sandboxId: ID!
|
||||
$input: CreateSandboxArtifactUploadSessionInput!
|
||||
) {
|
||||
sandbox {
|
||||
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
|
||||
artifact {
|
||||
id
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
|
||||
mutation FinalizeSandboxArtifact($artifactId: ID!) {
|
||||
sandbox {
|
||||
finalizeArtifact(artifactId: $artifactId) {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_ATTEMPTS = 3;
|
||||
const FINALIZE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class LegacySandboxApi {
|
||||
constructor(
|
||||
private readonly graphqlClient: Client,
|
||||
private readonly sandboxId: string
|
||||
) {}
|
||||
|
||||
public async markReadyAsync({ signal }: WorkerApiRequestOptions = {}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId: this.sandboxId },
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: { name: string; filename: string; size: number },
|
||||
{ signal, timeoutMs }: WorkerApiRequestOptions = {}
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
const requestSignal = AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutMs !== undefined ? [AbortSignal.timeout(timeoutMs)] : []),
|
||||
]);
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{ sandboxId: this.sandboxId, input },
|
||||
graphqlAbortContext(requestSignal)
|
||||
)
|
||||
.toPromise();
|
||||
requestSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
const { graphQLErrors } = result.error;
|
||||
const codes = Array.from(new Set(graphQLErrors.map(error => error.extensions?.errorCode)));
|
||||
throw new WorkerApiError(
|
||||
graphQLErrors.length > 0
|
||||
? graphQLErrors.map(error => error.message).join('\n')
|
||||
: result.error.message,
|
||||
result.error.response?.status,
|
||||
{ cause: result.error },
|
||||
codes.length === 1 && typeof codes[0] === 'string' ? codes[0] : undefined
|
||||
);
|
||||
}
|
||||
const { artifact, uploadSession } = result.data!.sandbox.createArtifactUploadSession;
|
||||
return {
|
||||
artifact,
|
||||
uploadSession: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async finalizeArtifactAsync(
|
||||
artifactId: string,
|
||||
{ signal }: WorkerApiRequestOptions = {}
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const timeoutSignal = AbortSignal.timeout(FINALIZE_TIMEOUT_MS);
|
||||
const finalizeSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
try {
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
|
||||
{ artifactId },
|
||||
graphqlAbortContext(finalizeSignal)
|
||||
)
|
||||
.toPromise();
|
||||
finalizeSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
|
||||
if (downloadUrl === null) {
|
||||
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
|
||||
}
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
timeoutSignal.aborted ||
|
||||
(error instanceof CombinedError && isTransientGraphqlError(error));
|
||||
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * attempt, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLegacySandboxConnection({
|
||||
graphqlClient,
|
||||
env,
|
||||
sandboxId,
|
||||
}: {
|
||||
graphqlClient: Client;
|
||||
env: Env;
|
||||
sandboxId: string;
|
||||
}): { sandboxApi: LegacySandboxApi; credential: string; commandEnv: Env } {
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: credential, ...commandEnv } = env;
|
||||
if (!credential) {
|
||||
throw new SystemError(
|
||||
'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN. ' +
|
||||
'www gives one of them to every sandbox job. Start a new sandbox, and contact support if ' +
|
||||
'it happens again.'
|
||||
);
|
||||
}
|
||||
return { sandboxApi: new LegacySandboxApi(graphqlClient, sandboxId), credential, commandEnv };
|
||||
}
|
||||
|
||||
function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
const status: number | undefined = error.response?.status;
|
||||
return status === undefined ? error.networkError !== undefined : status >= 500;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { killProcessGroup } from '../../utils/processes';
|
||||
export async function preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier,
|
||||
appiumHome,
|
||||
prepared = false,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -16,6 +17,8 @@ export async function preparePreinstalledWdaAsync({
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
/** A prepared Appium home that already holds the runner. */
|
||||
prepared?: boolean;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
@@ -32,6 +35,13 @@ export async function preparePreinstalledWdaAsync({
|
||||
|
||||
const outdir = path.join(appiumHome, 'preinstalled-wda');
|
||||
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
|
||||
if (prepared) {
|
||||
logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.');
|
||||
return {
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// The driver selects the release matching its installed WDA and the host architecture.
|
||||
// Keep the Simulator runner intact; removing its XCTest frameworks breaks launch.
|
||||
|
||||
@@ -11,7 +11,6 @@ import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxArtifactUploadSession, type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
@@ -29,7 +28,7 @@ export class SandboxArtifactUploadManager {
|
||||
|
||||
public constructor(
|
||||
private readonly options: {
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -88,7 +87,7 @@ export async function startSandboxArtifactUploadAsync({
|
||||
name,
|
||||
signal,
|
||||
}: {
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
filePath: string;
|
||||
name: string;
|
||||
@@ -157,7 +156,7 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
|
||||
async function createSandboxArtifactUploadSessionAsync(
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: SandboxWorkerApi,
|
||||
input: { name: string; filename: string; size: number },
|
||||
signal: AbortSignal
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { performance } from 'node:perf_hooks';
|
||||
import { readBytesAsync, readTextAsync } from '../../utils/files';
|
||||
import { detectFileType } from '../../utils/files/filetypes';
|
||||
import { SandboxArtifactUploadManager } from './sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { ShellSessionManager } from './shellSessionManager';
|
||||
|
||||
@@ -37,7 +36,7 @@ export function createSandboxCommandImplementations({
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
}): {
|
||||
commandImplementations: SandboxDaemonCommandImplementations;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from './sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
@@ -27,7 +26,7 @@ export interface SandboxDaemonOptions {
|
||||
signal?: AbortSignal;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Env, type EnvironmentSecret, type Job } from '@expo/eas-build-job';
|
||||
import { type Env, type EnvironmentSecret, type Job, SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
@@ -97,3 +97,19 @@ export function getSandboxWorkerApi(ctx: {
|
||||
const token = getSandboxWorkerTokenFromEnvironmentSecrets(ctx.job.secrets?.environmentSecrets);
|
||||
return token ? new SandboxWorkerApi(getExpoApiV2BaseUrl(ctx), token) : null;
|
||||
}
|
||||
|
||||
export function requireSandboxWorkerApi(ctx: {
|
||||
job: Job;
|
||||
env: Env;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}): SandboxWorkerApi {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
if (!workerApi) {
|
||||
throw new SystemError(
|
||||
`This sandbox job has no ${SANDBOX_WORKER_TOKEN_ENV} secret. ` +
|
||||
'www gives one to every sandbox job, and the worker needs it to call the sandbox API. ' +
|
||||
'Start a new sandbox, and contact support if it happens again.'
|
||||
);
|
||||
}
|
||||
return workerApi;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
|
||||
import { type DeviceSessionServer } from './deviceSessionHost';
|
||||
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
|
||||
@@ -28,6 +30,7 @@ export function createWarmDeviceSessionServer(
|
||||
}
|
||||
return {
|
||||
port: warm.server.port,
|
||||
serveSimVersion: warm.serveSimVersion,
|
||||
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
|
||||
// Set by prepare-tools, which the host runs before it installs the app.
|
||||
get preinstalledApp() {
|
||||
@@ -68,14 +71,34 @@ export function createWarmDeviceSessionServer(
|
||||
*/
|
||||
export async function runOnWarmIosSimulatorAsync<T>(
|
||||
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
|
||||
run: (warm?: WarmIosSession) => Promise<T>
|
||||
run: (warm?: WarmIosSession, packageVersion?: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const warm = await tryClaimWarmIosSessionAsync(claim);
|
||||
let packageVersion = claim.packageVersion;
|
||||
if (packageVersion !== undefined && claim.env.EAS_USE_WARM_SIMULATOR === '1') {
|
||||
try {
|
||||
packageVersion = await resolveSimulatorPackageVersionAsync(
|
||||
claim.engine,
|
||||
packageVersion,
|
||||
claim
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await stopWarmSimulatorAsync(claim.env);
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Could not stop warmup after version resolution failed.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const warm = await tryClaimWarmIosSessionAsync({ ...claim, packageVersion });
|
||||
if (!warm) {
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
try {
|
||||
return await run(warm);
|
||||
return await run(warm, packageVersion);
|
||||
} catch (error) {
|
||||
try {
|
||||
await warm.process.stopAsync();
|
||||
@@ -90,6 +113,6 @@ export async function runOnWarmIosSimulatorAsync<T>(
|
||||
}
|
||||
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
|
||||
claim.signal?.throwIfAborted();
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
@@ -9,7 +10,11 @@ import { readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
|
||||
|
||||
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
|
||||
const PackageVersionSchema = z.string().refine(version => semver.valid(version) !== null);
|
||||
const ClaimSchema = z.object({
|
||||
serveSimVersion: PackageVersionSchema.optional(),
|
||||
agentDeviceVersion: PackageVersionSchema.optional(),
|
||||
argentVersion: PackageVersionSchema.optional(),
|
||||
device: z.object({ udid: z.string(), name: z.string() }),
|
||||
server: ServiceSchema,
|
||||
agentDeviceStateDir: z.string(),
|
||||
@@ -53,10 +58,6 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Warm tools run the latest release with the VM's Simulator settings.
|
||||
if (packageVersion && packageVersion !== 'latest') {
|
||||
throw new Error('A pinned package_version needs a cold start.');
|
||||
}
|
||||
const setting = Object.keys(env).find(
|
||||
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
|
||||
);
|
||||
@@ -71,6 +72,15 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
const claim = ClaimSchema.parse(
|
||||
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
|
||||
);
|
||||
const selectedVersion = {
|
||||
'serve-sim': claim.serveSimVersion,
|
||||
'agent-device': claim.agentDeviceVersion,
|
||||
argent: claim.argentVersion,
|
||||
appium: undefined,
|
||||
}[engine];
|
||||
if (packageVersion !== undefined && selectedVersion !== packageVersion) {
|
||||
throw new Error('The warm tool does not match the requested package version.');
|
||||
}
|
||||
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
|
||||
let stopping: Promise<void> | undefined;
|
||||
const session: WarmIosSession = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
@@ -202,7 +203,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
return deviceIdentifier.toUpperCase() as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
@@ -284,9 +285,23 @@ export namespace IosSimulatorUtils {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
|
||||
env,
|
||||
});
|
||||
const { stdout: enumerateStdout } = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'io', udid, 'enumerate'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
udid,
|
||||
'screenshot',
|
||||
...displayFlagForScreenshot(enumerateStdout),
|
||||
readinessScreenshotPath,
|
||||
],
|
||||
{ env }
|
||||
);
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
@@ -298,22 +313,30 @@ export namespace IosSimulatorUtils {
|
||||
);
|
||||
await fs.promises.rm(readinessScreenshotPath, { force: true });
|
||||
|
||||
// Wait for data migration to complete before declaring the simulator ready
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
const device = await getDeviceAsync({ udid, env });
|
||||
const runtimes = await getAvailableRuntimesAsync({ env });
|
||||
const iosVersion = semver.coerce(
|
||||
runtimes.find(runtime => runtime.identifier === device?.runtime)?.version
|
||||
);
|
||||
// From iOS 27.2 on, `com.apple.datamigrator` stays resident after the boot's data migration, so
|
||||
// waiting for it to exit never ends; earlier runtimes exit it within about a minute of boot.
|
||||
if (!iosVersion || semver.lt(iosVersion, '27.2.0')) {
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableApsdAsync({
|
||||
@@ -522,6 +545,20 @@ async function isLaunchctlServiceLoadedAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `simctl io screenshot` refuses to choose between the iPhone Duo's two integrated screens on
|
||||
* iOS 27.2, while the simctl in Xcode 15.3 rejects a numeric `--display` ("Device does not have a
|
||||
* '1' display port"), so the flag is passed only when there is a choice to make.
|
||||
*/
|
||||
function displayFlagForScreenshot(enumerateStdout: string): string[] {
|
||||
// `simctl io enumerate` lists each screen as an indented "(n) Name:" header followed by its fields.
|
||||
const screens = enumerateStdout.split(/^ {4}\(\d+\) .*:$/m);
|
||||
const integratedScreenIds = screens
|
||||
.filter(screen => /^\s*Screen Type: Integrated$/m.test(screen))
|
||||
.flatMap(screen => screen.match(/^\s*Screen ID: (\d+)$/m)?.[1] ?? []);
|
||||
return integratedScreenIds.length > 1 ? [`--display=${integratedScreenIds[0]}`] : [];
|
||||
}
|
||||
|
||||
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
|
||||
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
|
||||
if (!matches) {
|
||||
|
||||
@@ -252,24 +252,179 @@ describe('IosSimulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.waitForReadyAsync, () => {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
|
||||
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
mockSimulator({});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
'test-udid',
|
||||
'screenshot',
|
||||
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
|
||||
],
|
||||
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('names the first integrated screen when the device has several (iPhone Duo on iOS 27.2)', async () => {
|
||||
// Connected screens from `xcrun simctl io <udid> enumerate` on an iPhone Duo (iOS 27.2).
|
||||
mockSimulator({
|
||||
version: '27.2',
|
||||
connectedScreens: ` Connected Screens:
|
||||
(1) LCD:
|
||||
Screen ID: 1
|
||||
Screen Type: Integrated
|
||||
(2) TVOut:
|
||||
Screen ID: 2
|
||||
Screen Type: TVOut
|
||||
(3) LCD-1:
|
||||
Screen ID: 3
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', '--display=1', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the first listed integrated screen, skipping screens that are not integrated', async () => {
|
||||
mockSimulator({
|
||||
connectedScreens: ` Connected Screens:
|
||||
(1) TVOut:
|
||||
Screen ID: 1
|
||||
Screen Type: TVOut
|
||||
(2) LCD-1:
|
||||
Screen ID: 5
|
||||
Screen Type: Integrated
|
||||
(3) LCD:
|
||||
Screen ID: 3
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', '--display=5', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the plain screenshot when the device has one integrated screen', async () => {
|
||||
mockSimulator({
|
||||
connectedScreens: ` Connected Screens:
|
||||
(2) TVOut:
|
||||
Screen ID: 2
|
||||
Screen Type: TVOut
|
||||
(1) LCD:
|
||||
Screen ID: 1
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['27.2', '27.3', '28.0'])(
|
||||
'does not wait for com.apple.datamigrator to exit on iOS %s, where it stays resident',
|
||||
async version => {
|
||||
mockSimulator({ version, dataMigratorRunning: true });
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('ps', expect.anything(), expect.anything());
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['27.1', '26.3.1', '18.6'])(
|
||||
'waits for com.apple.datamigrator to exit on iOS %s',
|
||||
async version => {
|
||||
mockSimulator({ version, dataMigratorRunning: true });
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('ps', ['-eo', 'pid,comm'], { env: process.env });
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for com.apple.datamigrator to exit when the device is not listed', async () => {
|
||||
mockSimulator({ listed: false, dataMigratorRunning: true });
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
});
|
||||
|
||||
it('waits for com.apple.datamigrator to exit when the runtime reports no version', async () => {
|
||||
mockSimulator({ version: null, dataMigratorRunning: true });
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
});
|
||||
|
||||
function mockSimulator({
|
||||
version = '26.4',
|
||||
listed = true,
|
||||
connectedScreens = '',
|
||||
dataMigratorRunning = false,
|
||||
}: {
|
||||
version?: string | null;
|
||||
listed?: boolean;
|
||||
connectedScreens?: string;
|
||||
dataMigratorRunning?: boolean;
|
||||
}): void {
|
||||
const runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-26-4';
|
||||
const otherRuntime = {
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2',
|
||||
version: '27.2',
|
||||
};
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'ps') {
|
||||
return {
|
||||
stdout: dataMigratorRunning
|
||||
? ' 123 /sbin/launchd_sim\n 456 /Library/Developer/CoreSimulator/Volumes/iOS_23E244/XPCServices/com.apple.datamigrator.xpc/com.apple.datamigrator\n'
|
||||
: ' 123 /sbin/launchd_sim\n',
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('runtimes')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
otherRuntime,
|
||||
version === null ? { identifier: runtime } : { identifier: runtime, version },
|
||||
],
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('list')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
devices: { [runtime]: listed ? [{ udid: 'test-udid' }] : [] },
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('enumerate')) {
|
||||
return { stdout: connectedScreens, stderr: '' } as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
}
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.disableApsdAsync, () => {
|
||||
@@ -427,6 +582,15 @@ describe('IosSimulatorUtils', () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uppercases a udid given in lowercase, matching how simctl lists devices', async () => {
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: '8027f627-9534-4679-85bf-0f14aff228e8' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
|
||||
});
|
||||
|
||||
it('resolves a device name to the first available device with that name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveSimulatorPackageVersionAsync } from '../simulatorPackageVersion';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it.each(['1.2.3', 'v1.2.3', '1.2.3-beta.1', '1.2.3-beta.1+build.2', 'v1.2.3+build.2'])(
|
||||
'compares an exact request without a registry call (%s)',
|
||||
async requested => {
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', requested, { env: {} })
|
||||
).resolves.toBe(requested.replace(/^v/, ''));
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['serve-sim', '@expo/serve-sim'],
|
||||
['agent-device', 'agent-device'],
|
||||
['argent', '@swmansion/argent'],
|
||||
['appium', 'appium'],
|
||||
] as const)('checks the current dist-tag for %s', async (engine, name) => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '"1.2.4"' } as never);
|
||||
const env = { NPM_CONFIG_REGISTRY: 'https://registry.example' };
|
||||
const signal = new AbortController().signal;
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync(engine, 'latest', { env, signal })
|
||||
).resolves.toBe('1.2.4');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${name}@latest`,
|
||||
'version',
|
||||
'--json',
|
||||
'--prefer-online',
|
||||
'--prefer-offline=false',
|
||||
'--offline=false',
|
||||
'--fetch-retries=0',
|
||||
],
|
||||
{ env, signal, stdio: 'pipe', timeout: 30_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('selects the newest matching version from range metadata without relying on output order', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '["3.9.0","3.10.0","3.8.0"]' } as never);
|
||||
await expect(resolveSimulatorPackageVersionAsync('appium', '^3', { env: {} })).resolves.toBe(
|
||||
'3.10.0'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['not json', '{}', '[]', '"latest"'])(
|
||||
'rejects unusable registry output (%s)',
|
||||
async stdout => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout } as never);
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {} })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves registry lookup failures instead of trusting the prepared version', async () => {
|
||||
const error = new Error('registry unavailable');
|
||||
jest.mocked(spawn).mockRejectedValue(error);
|
||||
await expect(resolveSimulatorPackageVersionAsync('argent', 'latest', { env: {} })).rejects.toBe(
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('does not look up a cancelled request', async () => {
|
||||
const signal = AbortSignal.abort(new Error('cancelled'));
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {}, signal })
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator';
|
||||
import {
|
||||
readPreparedSimulatorToolAsync,
|
||||
runSimulatorLauncherAsync,
|
||||
stopWarmSimulatorAsync,
|
||||
} from '../warmSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node:timers/promises');
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
@@ -11,12 +20,86 @@ beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never);
|
||||
jest.spyOn(os, 'uptime').mockReturnValue(600);
|
||||
jest.spyOn(fs, 'access').mockRejectedValue(new Error('no startup script'));
|
||||
jest.mocked(sleepAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('reads different prepared tool schemas without Appium-specific fields', async () => {
|
||||
const directory = path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup');
|
||||
const appium = { home: path.join(directory, 'appium'), version: '3.9.0', xcuitest: '12.22.8' };
|
||||
const otherTool = { version: '2.4.0' };
|
||||
const expoGo = { sdk: '55.0.0', url: 'https://example.test/ExpoGo.tar.gz' };
|
||||
const read = jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({ tools: { appium, 'other-tool': otherTool, 'expo-go': expoGo } })
|
||||
);
|
||||
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync(
|
||||
'appium',
|
||||
z.object({ home: z.string(), version: z.string(), xcuitest: z.string() })
|
||||
)
|
||||
).resolves.toEqual(appium);
|
||||
const prepared = await readPreparedSimulatorToolAsync(
|
||||
'other-tool',
|
||||
z.object({ version: z.string() })
|
||||
);
|
||||
expect(prepared?.version).toBe('2.4.0');
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync('expo-go', z.object({ sdk: z.string(), url: z.string() }))
|
||||
).resolves.toEqual(expoGo);
|
||||
expect(read.mock.calls).toEqual([
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
]);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['missing-tool', 'toString'])(
|
||||
'does not invent %s metadata from schema defaults or inherited properties',
|
||||
async tool => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{"tools":{}}');
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync(tool, z.unknown().default({ version: '1.2.3' }))
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing', 'invalid JSON', 'invalid fields', 'missing tool', 'older format'])(
|
||||
'ignores prepared tool metadata with %s',
|
||||
async failure => {
|
||||
const read = jest.spyOn(fs, 'readFile');
|
||||
switch (failure) {
|
||||
case 'missing':
|
||||
read.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
||||
break;
|
||||
case 'invalid JSON':
|
||||
read.mockResolvedValue('not JSON');
|
||||
break;
|
||||
case 'invalid fields':
|
||||
read.mockResolvedValue(JSON.stringify({ tools: { tool: { version: 42 } } }));
|
||||
break;
|
||||
case 'missing tool':
|
||||
read.mockResolvedValue('{"tools":{}}');
|
||||
break;
|
||||
case 'older format':
|
||||
read.mockResolvedValue('{"appium":"3.9.0","xcuitest":"12.22.8","home":"/old/home"}');
|
||||
break;
|
||||
}
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync('tool', z.object({ version: z.string() }))
|
||||
).resolves.toBeUndefined();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('runs the launcher version that start recorded and returns its output', async () => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}');
|
||||
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe(
|
||||
@@ -59,3 +142,79 @@ it('does nothing on other platforms', async () => {
|
||||
await stopWarmSimulatorAsync({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
const warmEnv = { EAS_USE_WARM_SIMULATOR: '1', EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
|
||||
it('waits for startup to write state, then claims with the recorded launcher version', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValue('{"launcherVersion":"27.6.0"}');
|
||||
await runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv });
|
||||
expect(sleepAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['x', '@expo/[email protected]', 'claim', 'serve-sim'],
|
||||
expect.objectContaining({ env: warmEnv })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a later session request', ['claim', 'serve-sim'], warmEnv, 300, true],
|
||||
['a system without startup', ['claim', 'serve-sim'], warmEnv, 10, false],
|
||||
['a job without warmup', ['claim', 'serve-sim'], {}, 10, true],
|
||||
['stop', ['stop'], warmEnv, 10, true],
|
||||
['prepare-tools', ['prepare-tools'], warmEnv, 10, true],
|
||||
])('does not wait for %s', async (_name, args, env, uptime, installed) => {
|
||||
jest.mocked(os.uptime).mockReturnValue(uptime);
|
||||
if (installed) {
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
}
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
await runSimulatorLauncherAsync(args, { env });
|
||||
expect(sleepAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('times out before invoking claim so its caller can stop warmup before starting cold', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async delay => {
|
||||
now += delay ?? 0;
|
||||
});
|
||||
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv })).rejects.toThrow(
|
||||
'Timed out waiting for the warm Simulator launcher to start.'
|
||||
);
|
||||
expect(now).toBe(60_000);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
await stopWarmSimulatorAsync(warmEnv);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['x', '@expo/simulator-launcher@latest', 'stop'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels the missing-state wait without invoking claim', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
const controller = new AbortController();
|
||||
jest.mocked(sleepAsync).mockImplementation(async () => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
});
|
||||
await expect(
|
||||
runSimulatorLauncherAsync(['claim', 'serve-sim'], {
|
||||
env: warmEnv,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PackageNames = {
|
||||
'serve-sim': '@expo/serve-sim',
|
||||
'agent-device': 'agent-device',
|
||||
argent: '@swmansion/argent',
|
||||
appium: 'appium',
|
||||
};
|
||||
|
||||
/** Resolve an explicit request once, so comparison and installation use the same version. */
|
||||
export async function resolveSimulatorPackageVersionAsync(
|
||||
engine: keyof typeof PackageNames,
|
||||
requested: string,
|
||||
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
const exact = semver.parse(requested);
|
||||
if (exact) {
|
||||
return exact.version + (exact.build.length ? `+${exact.build.join('.')}` : '');
|
||||
}
|
||||
// Use npm's configured registry and revalidate metadata instead of trusting a cached dist-tag.
|
||||
const { stdout } = await spawn(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${PackageNames[engine]}@${requested}`,
|
||||
'version',
|
||||
'--json',
|
||||
'--prefer-online',
|
||||
'--prefer-offline=false',
|
||||
'--offline=false',
|
||||
'--fetch-retries=0',
|
||||
],
|
||||
{ env, signal, stdio: 'pipe', timeout: 30_000 }
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const version = z.string().refine(value => semver.valid(value) !== null);
|
||||
const versions = z.union([version, z.array(version)]).parse(JSON.parse(stdout));
|
||||
const candidates = typeof versions === 'string' ? [versions] : versions;
|
||||
const resolved = semver.rsort(candidates)[0];
|
||||
if (!resolved) {
|
||||
throw new Error(`Could not resolve the requested ${engine} package version.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { type ZodType, z } from 'zod';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -8,6 +12,28 @@ import {
|
||||
resolvePackageExec,
|
||||
} from './packageManager';
|
||||
|
||||
/** Read one tool's prepared metadata; its consumer defines the schema and compatibility checks. */
|
||||
export async function readPreparedSimulatorToolAsync<T>(
|
||||
tool: string,
|
||||
schema: ZodType<T>
|
||||
): Promise<T | undefined> {
|
||||
const manifestPath = path.join(
|
||||
os.homedir(),
|
||||
'Library',
|
||||
'Caches',
|
||||
'expo-simulator-startup',
|
||||
'prepared.json'
|
||||
);
|
||||
try {
|
||||
const manifest = z
|
||||
.object({ tools: z.record(z.string(), z.unknown()) })
|
||||
.parse(JSON.parse(await fs.readFile(manifestPath, 'utf8')));
|
||||
return Object.hasOwn(manifest.tools, tool) ? schema.parse(manifest.tools[tool]) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a Simulator launcher command with the version that `start` recorded, which warmup cached.
|
||||
* The output can contain credentials, so it is returned and never logged.
|
||||
@@ -16,10 +42,29 @@ export async function runSimulatorLauncherAsync(
|
||||
launcherArgs: string[],
|
||||
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
const version = await fs
|
||||
signal?.throwIfAborted();
|
||||
const contents = await fs
|
||||
.readFile('/tmp/expo-simulator-startup.json', 'utf8')
|
||||
.then(text => JSON.parse(text).launcherVersion)
|
||||
.catch(() => undefined);
|
||||
.catch(async error => {
|
||||
if (
|
||||
error.code === 'ENOENT' &&
|
||||
launcherArgs[0] === 'claim' &&
|
||||
env.EAS_USE_WARM_SIMULATOR === '1' &&
|
||||
process.platform === 'darwin' &&
|
||||
os.uptime() < 300 &&
|
||||
(await fs.access('/usr/local/share/vm-tools/startup.sh').then(
|
||||
() => true,
|
||||
() => false
|
||||
))
|
||||
) {
|
||||
return await waitForLauncherStateFileAsync(signal);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
let version: unknown;
|
||||
try {
|
||||
version = JSON.parse(contents ?? '{}').launcherVersion;
|
||||
} catch {}
|
||||
const { command, args } = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.BUN),
|
||||
[
|
||||
@@ -31,6 +76,27 @@ export async function runSimulatorLauncherAsync(
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** A session request can arrive before startup writes launcher state. */
|
||||
async function waitForLauncherStateFileAsync(signal?: AbortSignal): Promise<string> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
return await fs.readFile('/tmp/expo-simulator-startup.json', 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
// The claim caller must still stop warmup before it can start cold.
|
||||
throw new Error('Timed out waiting for the warm Simulator launcher to start.');
|
||||
}
|
||||
await sleepAsync(Math.min(250, remaining), undefined, { signal });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops warmup; resolves only after the launcher confirms cleanup. */
|
||||
export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.5.0"
|
||||
"@expo/steps": "27.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+152
-150
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/eas-json": "27.5.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/eas-json": "27.6.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable,
|
||||
buildObserveCustomEventsJson,
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
@@ -42,6 +43,7 @@ jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
|
||||
const mockBuildObserveCustomEventsTable = jest.mocked(buildObserveCustomEventsTable);
|
||||
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
|
||||
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable
|
||||
@@ -299,6 +301,103 @@ describe(ObserveEvents, () => {
|
||||
expect(options.sessionId).toBe('session-xyz');
|
||||
});
|
||||
|
||||
it('passes repeated --property flags as property filters', async () => {
|
||||
const command = createCommand([
|
||||
'expo.feedback',
|
||||
'--property',
|
||||
'expo.feedback.id=fb-1',
|
||||
'--property',
|
||||
'note=a=b',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([
|
||||
{ key: 'expo.feedback.id', value: 'fb-1' },
|
||||
{ key: 'note', value: 'a=b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists events instead of the event-name summary when --property is set with no positional arg', async () => {
|
||||
const command = createCommand(['--property', 'expo.feedback.id=fb-1']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([{ key: 'expo.feedback.id', value: 'fb-1' }]);
|
||||
});
|
||||
|
||||
it('rejects --property without "="', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'expo.feedback.id']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --property with an empty key', async () => {
|
||||
const command = createCommand(['my_event', '--property', '=fb-1']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --property with an empty value', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'note=']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/value cannot be empty/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects more than 5 --property flags', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--property',
|
||||
'a=1',
|
||||
'--property',
|
||||
'b=2',
|
||||
'--property',
|
||||
'c=3',
|
||||
'--property',
|
||||
'd=4',
|
||||
'--property',
|
||||
'e=5',
|
||||
'--property',
|
||||
'f=6',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--property can be used up to 5 times/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts exactly 5 --property flags', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--property',
|
||||
'a=1',
|
||||
'--property',
|
||||
'b=2',
|
||||
'--property',
|
||||
'c=3',
|
||||
'--property',
|
||||
'd=4',
|
||||
'--property',
|
||||
'e=5',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([
|
||||
{ key: 'a', value: '1' },
|
||||
{ key: 'b', value: '2' },
|
||||
{ key: 'c', value: '3' },
|
||||
{ key: 'd', value: '4' },
|
||||
{ key: 'e', value: '5' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a repeated --property key', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'a=1', '--property', 'a=2']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/key "a" is used more than once/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
@@ -308,6 +407,7 @@ describe(ObserveEvents, () => {
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
expect(options.sessionId).toBeUndefined();
|
||||
expect(options.propertyFilters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
|
||||
@@ -395,6 +495,64 @@ describe(ObserveEvents, () => {
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--session-id', 'session-xyz'],
|
||||
['--property', 'expo.feedback.id=fb-1'],
|
||||
])(
|
||||
'does not suggest event names when an event name and %s return 0 events',
|
||||
async (flag, value) => {
|
||||
const command = createCommand(['my_event', flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eventName: 'my_event' })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('emits empty events JSON without suggestions when an event name and --session-id return 0 events and --json is set', async () => {
|
||||
const pageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({ events: [], pageInfo });
|
||||
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--session-id',
|
||||
'session-xyz',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith([], pageInfo);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--app-version', '2.1.0'],
|
||||
['--build-number', '42'],
|
||||
['--update-id', 'update-xyz'],
|
||||
])(
|
||||
'does not suggest event names when an event name and %s return 0 events',
|
||||
async (flag, value) => {
|
||||
const command = createCommand(['my_event', flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eventName: 'my_event' })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { AppObserveUserEventPropertyFilter } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
const MAX_PROPERTY_FILTERS = 5;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override description =
|
||||
@@ -61,6 +63,11 @@ export default class ObserveEvents extends EasCommand {
|
||||
description:
|
||||
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
|
||||
}),
|
||||
property: Flags.custom<AppObserveUserEventPropertyFilter>({
|
||||
description: `Filter by event property (key=value, exact match). Repeat up to ${MAX_PROPERTY_FILTERS} times to require all (each key may be used once). Without an event name, lists matching events.`,
|
||||
multiple: true,
|
||||
parse: parsePropertyFilterAsync,
|
||||
})(),
|
||||
'all-events': Flags.boolean({
|
||||
description:
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
@@ -85,6 +92,22 @@ export default class ObserveEvents extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (flags.property) {
|
||||
if (flags.property.length > MAX_PROPERTY_FILTERS) {
|
||||
throw new Error(
|
||||
`--property can be used up to ${MAX_PROPERTY_FILTERS} times, but got ${flags.property.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
const keys = flags.property.map(filter => filter.key);
|
||||
const repeatedKey = keys.find((key, index) => keys.indexOf(key) !== index);
|
||||
if (repeatedKey) {
|
||||
throw new Error(
|
||||
`--property key "${repeatedKey}" is used more than once. Each key can be filtered by only one value.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -101,10 +124,10 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
|
||||
// A session ID narrows to a single session, so show that session's events
|
||||
// A session ID or property filter narrows the events, so show them
|
||||
// (like --all-events) instead of the account-wide name+count summary, which
|
||||
// has no session filter.
|
||||
if (!args.eventName && !flags['all-events'] && !flags['session-id']) {
|
||||
// supports neither filter.
|
||||
if (!args.eventName && !flags['all-events'] && !flags['session-id'] && !flags.property) {
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
@@ -144,10 +167,22 @@ export default class ObserveEvents extends EasCommand {
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
environment: flags.environment,
|
||||
propertyFilters: flags.property,
|
||||
})
|
||||
);
|
||||
|
||||
if (args.eventName && events.length === 0) {
|
||||
// A session, app version, build number, update ID, or property may not
|
||||
// contain the event even when the name is valid, so only suggest other names
|
||||
// when the event name is the sole narrowing filter.
|
||||
if (
|
||||
args.eventName &&
|
||||
events.length === 0 &&
|
||||
!flags['session-id'] &&
|
||||
!flags['app-version'] &&
|
||||
!flags['build-number'] &&
|
||||
!flags['update-id'] &&
|
||||
!flags.property
|
||||
) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
@@ -189,3 +224,16 @@ export default class ObserveEvents extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split on the first "=" so values may contain "=".
|
||||
async function parsePropertyFilterAsync(input: string): Promise<AppObserveUserEventPropertyFilter> {
|
||||
const separatorIndex = input.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(`Invalid property filter "${input}". Expected key=value format.`);
|
||||
}
|
||||
const value = input.slice(separatorIndex + 1);
|
||||
if (!value) {
|
||||
throw new Error(`Invalid property filter "${input}". The value cannot be empty.`);
|
||||
}
|
||||
return { key: input.slice(0, separatorIndex), value };
|
||||
}
|
||||
@@ -99,6 +99,18 @@ describe('fetchObserveCustomEventsAsync', () => {
|
||||
expect(filter).not.toHaveProperty('appUpdateId');
|
||||
});
|
||||
|
||||
it('forwards propertyFilters when provided', async () => {
|
||||
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
propertyFilters: [{ key: 'expo.feedback.id', value: 'fb-1' }],
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
|
||||
expect(filter?.propertyFilters).toEqual([{ key: 'expo.feedback.id', value: 'fb-1' }]);
|
||||
});
|
||||
|
||||
it('does not send optional filters when their inputs are undefined', async () => {
|
||||
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
@@ -112,6 +124,7 @@ describe('fetchObserveCustomEventsAsync', () => {
|
||||
expect(filter).not.toHaveProperty('appVersion');
|
||||
expect(filter).not.toHaveProperty('appUpdateId');
|
||||
expect(filter).not.toHaveProperty('sessionId');
|
||||
expect(filter).not.toHaveProperty('propertyFilters');
|
||||
});
|
||||
|
||||
it('forwards after cursor when provided', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListFilter,
|
||||
AppObserveUserEventListOrderBy,
|
||||
AppObserveUserEventPropertyFilter,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
@@ -20,6 +21,7 @@ interface FetchCustomEventsOptions {
|
||||
updateId?: string;
|
||||
sessionId?: string;
|
||||
environment?: string;
|
||||
propertyFilters?: AppObserveUserEventPropertyFilter[];
|
||||
orderBy?: AppObserveUserEventListOrderBy;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ export async function fetchObserveCustomEventsAsync(
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.sessionId && { sessionId: options.sessionId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
...(options.propertyFilters?.length && { propertyFilters: options.propertyFilters }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.customEventListAsync(graphqlClient, {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.5.0",
|
||||
"@expo/build-tools": "27.6.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
|
||||
- Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
|
||||
- Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
|
||||
- Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
Warm iOS Simulators before an EAS job arrives. Requires macOS.
|
||||
The image installs vm-tools and writes its startup script at
|
||||
`/usr/local/share/vm-tools/startup.sh`:
|
||||
Prepare iOS Simulators and native tools, cache Expo Go and Appium files, and claim prepared sessions. Requires macOS.
|
||||
|
||||
Start the launcher with the devices you want to prepare:
|
||||
|
||||
```sh
|
||||
export EXPO_SIMULATOR_STARTUP=1
|
||||
@@ -21,26 +21,47 @@ exec bunx @expo/simulator-launcher@latest start
|
||||
- `prepare-tools`: continue the consumed claim after adoption. Keep global Argent, reuse the default
|
||||
agent-device runner or prepare a runner for the selected device. Appium waits for old XCTest cleanup.
|
||||
- `stop`: permanently end warmup for this boot and wait for all owned resources to be cleaned up.
|
||||
- `warmup`: populate Simulator and native tool caches. With `EXPO_SIMULATOR_STARTUP=1`,
|
||||
calls `prefetch`, boots the selected Simulators, installs Expo Go unless disabled, and waits
|
||||
for Argent and agent-device on the default device. It then drains owned processes and shuts
|
||||
down the selected Simulators. Prepared files and native build caches remain available.
|
||||
Incomplete preparation or cleanup fails. The preparation deadline is ten minutes; cleanup
|
||||
can take longer. Requires a fresh macOS boot before `start` has run. A repeat, or `start`
|
||||
after `warmup`, requires another boot. It does not start an Appium session or prepare an
|
||||
agent-device runner on secondary devices.
|
||||
- `prefetch`: cache the selected Expo Go archive for reuse. With `APPIUM_VERSION` and `XCUITEST_VERSION`, prepare Appium, XCUITest and WebDriverAgent files. It does not fetch serve-sim, agent-device or Argent. Progress goes to stderr; the command stops at the first failure.
|
||||
|
||||
Consumers use the launcher version recorded by `start` for session commands (`LAUNCHER_VERSION`
|
||||
below). Run these in another process:
|
||||
|
||||
```sh
|
||||
# Build-tools reads LAUNCHER_VERSION from the version recorded by start.
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim
|
||||
# After private serve-sim adoption confirms the selected device and native ACK:
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" prepare-tools
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" stop
|
||||
bun x "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim
|
||||
# After adoption confirms the selected device and native ACK:
|
||||
bun x "@expo/simulator-launcher@$LAUNCHER_VERSION" prepare-tools
|
||||
bun x "@expo/simulator-launcher@$LAUNCHER_VERSION" stop
|
||||
```
|
||||
|
||||
There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop;
|
||||
Warmup primes Bun’s exact-version cache before ready, with two attempts of up to 30 seconds. Its claim/stop calls use `--no-install`, retrying
|
||||
once with the registry only when Bun cannot find that cached binary. Priming failures warn.
|
||||
Before start has run,
|
||||
stop uses the normal package-manager path and still disables later warmup for this boot.
|
||||
To cache files without booting devices:
|
||||
|
||||
This flow requires the serve-sim release supporting port-based device adoption.
|
||||
```sh
|
||||
simulator-launcher prefetch
|
||||
```
|
||||
|
||||
To also prepare Simulators and native tools (`warmup` already calls `prefetch`):
|
||||
|
||||
```sh
|
||||
EXPO_SIMULATOR_STARTUP=1 simulator-launcher warmup
|
||||
```
|
||||
|
||||
Session adoption requires the serve-sim release supporting port-based device adoption.
|
||||
|
||||
One durable state file, `/tmp/expo-simulator-startup.json`, holds the lifecycle, launcher version,
|
||||
selected device, server credentials, claim and native identity. It is independent of HOME/TMPDIR. A short boot-scoped lock
|
||||
serializes updates; an abandoned lock fails closed. This private format requires a fresh VM.
|
||||
serializes updates; an abandoned lock fails closed. State is scoped to the current boot.
|
||||
`start` uses the supplied runtime package versions and records exact version inputs as
|
||||
`serveSimVersion`, `agentDeviceVersion` and `argentVersion` in this state and the claim. Consumers
|
||||
compare explicit package requests with these versions before reuse. Tags, ranges and unset inputs
|
||||
do not report an exact version. Build-tools also uses `serveSimVersion` for warm adoption and recordings.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -50,13 +71,74 @@ serializes updates; an abandoned lock fails closed. This private format requires
|
||||
trimmed and must each identify one available device. The first is the default. Unset keeps
|
||||
the shortest available iPhone name, as does an empty or blank value. Empty list entries, missing,
|
||||
ambiguous or duplicate devices fail clearly.
|
||||
- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: each defaults to `latest`.
|
||||
- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION` (`start`/`warmup` only): runtime
|
||||
package selections, each defaulting to `latest`. The launcher passes them to Bun without a separate
|
||||
version lookup. Callers that select exact versions can identify them in state and claims.
|
||||
`prefetch` ignores these settings and never records them in `prepared.json`.
|
||||
- `EXPO_GO_SDK_VERSION`: select an SDK for `prefetch` and `start` with an https iOS Simulator archive
|
||||
in the Expo versions API. Unset selects the latest stable SDK; a pin must be `x.y.z` and may select
|
||||
a beta. `start` reuses recorded SDK/archive metadata when it matches the supplied SDK, or when
|
||||
no SDK is supplied. Without matching metadata, startup selects from the API using this variable
|
||||
or the latest stable SDK. Ignored when `EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0`.
|
||||
- `APPIUM_VERSION`, `XCUITEST_VERSION` (`prefetch` only): exact versions (`x.y.z`), both or
|
||||
neither. Any other value makes `prefetch` fail.
|
||||
- `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations.
|
||||
- Agent-device idle timers default to off. Override with `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS`
|
||||
and `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds).
|
||||
|
||||
## Prepared files
|
||||
|
||||
`prefetch` writes `expo-go/` and `appium/` under `~/Library/Caches/expo-simulator-startup/`, and
|
||||
removes each one when its step is skipped. Each reader checks the files before it uses them and
|
||||
otherwise runs its normal download or install. Keep these directories when reusing prepared files.
|
||||
|
||||
- `expo-go/<sha256 of the archive URL>.tar.gz`: `start` uses the recorded URL when its SDK matches
|
||||
the supplied selection, or when no SDK is supplied. It checks the file with `gzip -t` before
|
||||
extraction. A missing or damaged archive is downloaded again from that URL.
|
||||
- `appium/`: an Appium home with the pinned xcuitest driver and WebDriverAgent runner.
|
||||
Prefetch checks the runner's bundle metadata and executable before recording it.
|
||||
Build-tools reuses it when the files match the manifest and Appium matches the current
|
||||
resolution of an explicit version request, including ranges and `latest`. Without an override,
|
||||
the prepared version must satisfy the default Appium 3 range. Build-tools does not write or
|
||||
remove this home. These checks do not load the driver or launch WDA; validate reuse with a
|
||||
real Appium session.
|
||||
|
||||
### Prepared tool manifest
|
||||
|
||||
`prefetch` writes `~/Library/Caches/expo-simulator-startup/prepared.json` after all requested
|
||||
steps and temporary-file cleanup succeed. Before refreshing caches, it removes the previous
|
||||
manifest. Skipped tools are absent from the new manifest; a failed preparation publishes no new one.
|
||||
|
||||
The JSON has a `tools` map:
|
||||
|
||||
- `appium.version`, `appium.xcuitest`, `appium.home`: the prepared Appium and XCUITest versions
|
||||
and the absolute Appium home path. This entry is written only after WDA preparation succeeds.
|
||||
|
||||
When Expo Go is enabled, `expo-go.sdk` and `expo-go.url` in `tools` record its selected SDK and archive URL.
|
||||
The SDK identifies the selected Expo release, not the app's binary version. With every step
|
||||
disabled or unset, the manifest is `{"tools":{}}`.
|
||||
|
||||
`start` reads only the optional Expo Go selection from this manifest. Missing or invalid Expo Go
|
||||
metadata, or metadata for a different supplied SDK, uses normal SDK/archive selection. Legacy
|
||||
serve-sim, agent-device and Argent entries are ignored, including malformed entries. Exact CLI
|
||||
version inputs are recorded only in the current boot's state and claim.
|
||||
|
||||
Build-tools' shared `readPreparedSimulatorToolAsync(tool, schema)` reader selects an entry from
|
||||
`tools` using the consumer's schema. Each consumer checks compatibility before reuse. Missing,
|
||||
invalid or older metadata is a cache miss and uses normal setup.
|
||||
|
||||
Keep the Expo Go archive and the whole Appium home, not only the manifest. Appium's home
|
||||
must stay at the recorded path under the same user. Rerun `prefetch` to regenerate the manifest
|
||||
when changing versions. Metadata does not prove that a driver loads or WDA launches; validate
|
||||
Appium reuse with a real session.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Native warmup still runs serve-sim, agent-device and Argent. Bun may cache their packages as a side
|
||||
effect; these incidental caches are retained. The caller supplies the requested package selections
|
||||
for each boot, and Bun reuses matching files or fetches the requested versions. Native build-cache
|
||||
reuse depends on the selected version and device.
|
||||
|
||||
- Ready means the default device booted and serve-sim answers. The apsd step and the Expo Go
|
||||
preinstall run in the background on every listed device. A claim does not wait for them; stop
|
||||
joins them.
|
||||
@@ -67,13 +149,15 @@ serializes updates; an abandoned lock fails closed. This private format requires
|
||||
- Agent-device prestarts on the default device only. A secondary claim prepares its runner after
|
||||
adoption and old-runner cleanup finish. One device-independent Argent server is reused for the claimed device.
|
||||
Appium waits for unused tools to stop before starting its own WDA.
|
||||
- Warmup downloads/extracts the latest stable Expo Go once and reuses it for the second device.
|
||||
- Warmup uses the prepared Expo Go selection, or the standalone SDK selection when none exists, once, from the
|
||||
prefetched archive (see
|
||||
[Prepared files](#prepared-files)) or by download, and reuses it for the second device.
|
||||
Each initial device gets a preinstall attempt; ordinary failures warn. Claim prevents new
|
||||
preinstalls. `prepare-tools` waits for an install still running on the claimed device and
|
||||
reports its version, because the job installs its app next. Installs on other devices continue;
|
||||
adoption may shut such a device down, and its install then fails with a warning. Terminal stop
|
||||
drains them and the download cleanup. Job app installs remain
|
||||
authoritative. Replacements do not preinstall again. The VM image provides ffmpeg.
|
||||
authoritative. Replacements do not preinstall again. `ffmpeg` must be available on PATH.
|
||||
- One start attempt and one accepted claim per boot. Failed cleanup never permits another session.
|
||||
Stop's caller timeout is not a global cleanup deadline or proof that teardown completed.
|
||||
A build-tools claim timeout uses normal startup only after confirmed terminal cleanup.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -28,8 +28,10 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"jest": "^29.7.0",
|
||||
"semver": "^7.6.2",
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import os from 'node:os';
|
||||
@@ -26,11 +27,27 @@ function createArchive(entries: { name: string; contents: Buffer }[]): Buffer {
|
||||
function installCalls() {
|
||||
return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'xcrun');
|
||||
}
|
||||
function archiveCalls() {
|
||||
return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file.startsWith('/usr/bin/'));
|
||||
}
|
||||
/** Writes the archive where prefetch stores it for `url`, under the mocked home directory. */
|
||||
async function writeCachedArchive(contents: Buffer, url = stableUrl): Promise<string> {
|
||||
const hash = createHash('sha256').update(url).digest('hex');
|
||||
const file = path.join(
|
||||
directory,
|
||||
'Library/Caches/expo-simulator-startup/expo-go',
|
||||
`${hash}.tar.gz`
|
||||
);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
let directory: string;
|
||||
let archive: Buffer;
|
||||
let controller: AbortController;
|
||||
let warmup: ReturnType<typeof startOn> | undefined;
|
||||
let sdkVersionPin: string | undefined;
|
||||
const stableUrl = 'https://example.test/Expo-Go-stable.tar.gz';
|
||||
const sdkVersions = {
|
||||
'57.0.0': {
|
||||
@@ -51,8 +68,8 @@ const sdkVersions = {
|
||||
};
|
||||
|
||||
/** Starts the shared download and preinstalls on one device, as warmup does for each device. */
|
||||
function startOn(udid: string, boot: Promise<unknown>) {
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
function startOn(udid: string, boot: Promise<unknown>, prepared?: { sdk: string; url: string }) {
|
||||
const preinstall = startExpoGoPreinstall(controller.signal, prepared);
|
||||
return {
|
||||
...preinstall,
|
||||
resultPromise: preinstall.preinstallOnDeviceAsync(udid, boot, controller.signal),
|
||||
@@ -60,11 +77,15 @@ function startOn(udid: string, boot: Promise<unknown>) {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Tests set the pin themselves. Keep the value of the caller's shell out of them.
|
||||
sdkVersionPin = process.env.EXPO_GO_SDK_VERSION;
|
||||
delete process.env.EXPO_GO_SDK_VERSION;
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'expo-go-unit-'));
|
||||
archive = createArchive([
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
]);
|
||||
jest.spyOn(os, 'tmpdir').mockReturnValue(directory);
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(directory);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(async url =>
|
||||
@@ -75,7 +96,9 @@ beforeEach(async () => {
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar' ? await runCommandAsync(file, args, signal) : ''
|
||||
file === '/usr/bin/tar' || file === '/usr/bin/gzip'
|
||||
? await runCommandAsync(file, args, signal)
|
||||
: ''
|
||||
);
|
||||
jest.spyOn(commands, 'throwIfCommandCloseTimedOut').mockImplementation(() => {});
|
||||
jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
@@ -85,6 +108,11 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
controller.abort();
|
||||
await warmup?.stopAsync('UDID').catch(() => {});
|
||||
if (sdkVersionPin === undefined) {
|
||||
delete process.env.EXPO_GO_SDK_VERSION;
|
||||
} else {
|
||||
process.env.EXPO_GO_SDK_VERSION = sdkVersionPin;
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
@@ -634,3 +662,259 @@ it('preinstalls all three configured devices from one shared archive through the
|
||||
process.env = environment;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the prefetched archive for the selected URL and keeps it after stop', async () => {
|
||||
const cache = await writeCachedArchive(archive);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const app = installCalls()[0][1][3];
|
||||
expect(app).toMatch(/\/simulator-expo-go-[^/]+\/cached\/ExpoGo\.app$/);
|
||||
expect(archiveCalls()).toEqual([
|
||||
['/usr/bin/gzip', ['-t', cache]],
|
||||
['/usr/bin/tar', ['-xzf', cache, '-C', app]],
|
||||
]);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
'Using the prefetched Expo Go archive for SDK 58.0.0.\n'
|
||||
);
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(await fs.readdir(directory)).toEqual(['Library']);
|
||||
expect((await fs.readFile(cache)).equals(archive)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['changed gzip CRC', 'truncated', 'no Info.plist', 'mode 000'] as const)(
|
||||
'warns and downloads when the prefetched archive is %s',
|
||||
async kind => {
|
||||
let contents = archive;
|
||||
if (kind === 'changed gzip CRC') {
|
||||
// tar alone accepts this archive; gzip -t rejects it.
|
||||
contents = Buffer.from(archive);
|
||||
contents[contents.length - 8] ^= 0xff;
|
||||
} else if (kind === 'truncated') {
|
||||
contents = archive.subarray(0, archive.length - 20);
|
||||
} else if (kind === 'no Info.plist') {
|
||||
contents = createArchive([{ name: 'other', contents: Buffer.from('x') }]);
|
||||
}
|
||||
const cache = await writeCachedArchive(contents);
|
||||
if (kind === 'mode 000') {
|
||||
await fs.chmod(cache, 0o000);
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not use the prefetched Expo Go archive')
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(installCalls()[0][1][3]).toMatch(/\/simulator-expo-go-[^/]+\/ExpoGo\.app$/);
|
||||
await warmup.stopAsync('UDID');
|
||||
await fs.chmod(cache, 0o644);
|
||||
expect((await fs.readFile(cache)).equals(contents)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('claim during the cached extraction cannot install or download', async () => {
|
||||
await writeCachedArchive(archive);
|
||||
const extraction = deferred();
|
||||
let extracting = false;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
extracting = true;
|
||||
await extraction.promise;
|
||||
}
|
||||
return file.startsWith('/usr/bin/') ? await runCommandAsync(file, args) : '';
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => extracting);
|
||||
await warmup.claimAsync('UDID');
|
||||
extraction.resolve();
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(await fs.readdir(directory)).toEqual(['Library']);
|
||||
} finally {
|
||||
extraction.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim during the cached gzip check starts no tar', async () => {
|
||||
await writeCachedArchive(archive);
|
||||
const check = deferred();
|
||||
let checking = false;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/gzip') {
|
||||
checking = true;
|
||||
await check.promise;
|
||||
}
|
||||
return file.startsWith('/usr/bin/') ? await runCommandAsync(file, args) : '';
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => checking);
|
||||
await warmup.claimAsync('UDID');
|
||||
check.resolve();
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(archiveCalls().map(([file]) => file)).toEqual(['/usr/bin/gzip']);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(await fs.readdir(directory)).toEqual(['Library']);
|
||||
} finally {
|
||||
check.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim during the cache lookup starts no gzip and writes no hit line', async () => {
|
||||
const cache = await writeCachedArchive(archive);
|
||||
const access = fs.access.bind(fs);
|
||||
jest.spyOn(fs, 'access').mockImplementation(async (file, mode) => {
|
||||
if (file === cache) {
|
||||
await warmup?.claimAsync('UDID');
|
||||
}
|
||||
await access(file, mode);
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(fs.access).toHaveBeenCalledWith(cache);
|
||||
expect(archiveCalls()).toEqual([]);
|
||||
expect(process.stderr.write).not.toHaveBeenCalledWith(expect.stringContaining('prefetched'));
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a close timeout in the cached gzip fails closed without a download', async () => {
|
||||
await writeCachedArchive(archive);
|
||||
const failure = new Error('gzip close not proved');
|
||||
jest.mocked(commands.runAsync).mockImplementationOnce(async file => {
|
||||
expect(file).toBe('/usr/bin/gzip');
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
throw failure;
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await expect(warmup.resultPromise).rejects.toBe(failure);
|
||||
await expect(warmup.stopAsync('UDID')).rejects.toBe(failure);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['no file', 'a file for another URL'])(
|
||||
'downloads as before with %s in the cache',
|
||||
async kind => {
|
||||
if (kind !== 'no file') {
|
||||
await writeCachedArchive(archive, 'https://example.test/old.tar.gz');
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(archiveCalls().map(([file]) => file)).toEqual(['/usr/bin/tar']);
|
||||
expect(process.stderr.write).not.toHaveBeenCalledWith(expect.stringContaining('prefetched'));
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not read the cache when the versions API fails', async () => {
|
||||
await writeCachedArchive(archive);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(archiveCalls()).toEqual([]);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not preinstall Expo Go')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the Expo Go of EXPO_GO_SDK_VERSION, not the latest stable one', async () => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '57.0.0';
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenLastCalledWith('https://example.test/old.tar.gz', expect.anything());
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
'Preinstalling Expo Go for SDK 57.0.0 on Simulator UDID.\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('a pinned SDK can be a beta that the versions API lists', async () => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '59.0.0';
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenLastCalledWith('https://example.test/beta.tar.gz', expect.anything());
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses the prefetched archive of the pinned SDK', async () => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '57.0.0';
|
||||
const cache = await writeCachedArchive(archive, 'https://example.test/old.tar.gz');
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(archiveCalls()[0]).toEqual(['/usr/bin/gzip', ['-t', cache]]);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
'Using the prefetched Expo Go archive for SDK 57.0.0.\n'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['62.0.0', 'latest', '58', 'constructor'])(
|
||||
'warns, downloads nothing and installs nothing when EXPO_GO_SDK_VERSION is %s',
|
||||
async pin => {
|
||||
process.env.EXPO_GO_SDK_VERSION = pin;
|
||||
await writeCachedArchive(archive);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(archiveCalls()).toEqual([]);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`No Expo Go Simulator archive is available for SDK ${pin}.`)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['no archive URL', {}],
|
||||
['an archive URL that is not https', { iosClientUrl: 'http://example.test/57.tar.gz' }],
|
||||
])('rejects a pinned SDK that the versions API lists with %s', async (_, release) => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '57.0.0';
|
||||
jest.mocked(fetch).mockResolvedValueOnce(Response.json({ sdkVersions: { '57.0.0': release } }));
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No Expo Go Simulator archive is available for SDK 57.0.0.')
|
||||
);
|
||||
});
|
||||
|
||||
it('an empty EXPO_GO_SDK_VERSION keeps the latest stable release', async () => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '';
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenLastCalledWith(stableUrl, expect.anything());
|
||||
});
|
||||
|
||||
it('uses the prepared Expo Go archive without a versions API request', async () => {
|
||||
await writeCachedArchive(archive);
|
||||
jest.mocked(fetch).mockRejectedValue(new Error('network unavailable'));
|
||||
warmup = startOn('UDID', Promise.resolve(), { sdk: '58.0.0', url: stableUrl });
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'downloads the recorded Expo Go URL if its cache is missing or damaged (damaged: %s)',
|
||||
async damaged => {
|
||||
if (damaged) {
|
||||
await writeCachedArchive(Buffer.from('damaged'));
|
||||
}
|
||||
process.env.EXPO_GO_SDK_VERSION = '59.0.0';
|
||||
warmup = startOn('UDID', Promise.resolve(), { sdk: '58.0.0', url: stableUrl });
|
||||
await warmup.resultPromise;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(stableUrl, expect.anything());
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
fs.writeFileSync(path.join(process.argv[2], 'child.pid'), String(process.pid));
|
||||
setTimeout(() => fs.writeFileSync(path.join(process.argv[2], 'late-write'), 'still running'), 1000);
|
||||
setInterval(() => {}, 1000);
|
||||
@@ -0,0 +1,11 @@
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const child = spawn(process.execPath, [path.join(__dirname, 'download-child.cjs'), process.argv[2]], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
if (process.argv[3] === 'exit') {
|
||||
child.unref();
|
||||
} else {
|
||||
setInterval(() => {}, 1000);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync, prepareToolsAsync } from '../commands/claim';
|
||||
import * as prefetch from '../commands/prefetch';
|
||||
import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import { mainAsync } from '../main';
|
||||
@@ -159,7 +160,133 @@ afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('warmup is finite, never claimable, and joins cleanup before returning', async () => {
|
||||
const prefetching = deferred<number>();
|
||||
jest.spyOn(prefetch, 'prefetchAsync').mockReturnValue(prefetching.promise);
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
const drain = deferred();
|
||||
jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockReturnValue(drain.promise);
|
||||
let done = false;
|
||||
const completion = mainAsync(['warmup']).then(value => {
|
||||
done = true;
|
||||
return value;
|
||||
});
|
||||
active.push(completion);
|
||||
try {
|
||||
await status('starting');
|
||||
expect(commands.start).not.toHaveBeenCalled();
|
||||
prefetching.resolve(0);
|
||||
await status('stopping');
|
||||
expect(done).toBe(false);
|
||||
expect(await readLauncherStateAsync(boot)).not.toHaveProperty('server');
|
||||
drain.resolve();
|
||||
expect(await completion).toBe(0);
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({ status: 'disabled' });
|
||||
await expect(mainAsync(['warmup'])).rejects.toThrow('fresh boot');
|
||||
} finally {
|
||||
prefetching.resolve(0);
|
||||
drain.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
test('interrupted prefetch fails after cleanup instead of reporting success', async () => {
|
||||
jest.spyOn(prefetch, 'prefetchAsync').mockImplementation(
|
||||
signal =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
})
|
||||
);
|
||||
const completion = mainAsync(['warmup']).catch(error => error);
|
||||
active.push(completion);
|
||||
await status('starting');
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
expect(await completion).toBeInstanceOf(Error);
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'disabled',
|
||||
error: expect.any(String),
|
||||
});
|
||||
expect(commands.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('prefetch failure propagates without starting native preparation', async () => {
|
||||
jest.spyOn(prefetch, 'prefetchAsync').mockRejectedValue(new Error('download failed'));
|
||||
await expect(mainAsync(['warmup'])).rejects.toThrow('download failed');
|
||||
expect(commands.start).not.toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'disabled',
|
||||
error: 'download failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('warmup rejects an active startup before prefetch can replace caches', async () => {
|
||||
const fetching = jest.spyOn(prefetch, 'prefetchAsync').mockResolvedValue(0);
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: 123,
|
||||
}));
|
||||
await expect(mainAsync(['warmup'])).rejects.toThrow('already been started');
|
||||
expect(fetching).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('warmup cleanup failure remains visible and cannot report completion', async () => {
|
||||
jest.spyOn(prefetch, 'prefetchAsync').mockResolvedValue(0);
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
jest
|
||||
.spyOn(processes, 'stopProcessAndDescendantsAsync')
|
||||
.mockRejectedValue(new Error('still running'));
|
||||
await expect(mainAsync(['warmup'])).rejects.toThrow('cleanup could not be confirmed');
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'stopping',
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('warmup requires startup enabled before replacing cached files', async () => {
|
||||
delete process.env.EXPO_SIMULATOR_STARTUP;
|
||||
const fetching = jest.spyOn(prefetch, 'prefetchAsync').mockResolvedValue(0);
|
||||
await expect(mainAsync(['warmup'])).rejects.toThrow('requires EXPO_SIMULATOR_STARTUP=1');
|
||||
expect(fetching).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('warmup rejects extra arguments before native work', async () => {
|
||||
await expect(mainAsync(['warmup', 'extra'])).rejects.toThrow('Usage: simulator-launcher warmup');
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('tool preparation deadline fails the command and still joins cleanup', async () => {
|
||||
const deadline = new AbortController();
|
||||
const timeout = AbortSignal.timeout;
|
||||
jest
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockImplementation(ms => (ms === 600_000 ? deadline.signal : timeout(ms)));
|
||||
jest.spyOn(prefetch, 'prefetchAsync').mockResolvedValue(0);
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
const entered = deferred();
|
||||
jest
|
||||
.mocked(serveSim.waitForServeSimReadyAsync)
|
||||
.mockImplementation(async (_udid, _server, signal) => {
|
||||
entered.resolve();
|
||||
return await new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
);
|
||||
});
|
||||
const completion = mainAsync(['warmup']).catch(error => error);
|
||||
active.push(completion);
|
||||
await entered.promise;
|
||||
deadline.abort(new Error('preparation deadline elapsed'));
|
||||
expect(await completion).toMatchObject({ message: 'preparation deadline elapsed' });
|
||||
expect(processes.stopProcessAndDescendantsAsync).toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'disabled',
|
||||
error: 'preparation deadline elapsed',
|
||||
});
|
||||
});
|
||||
|
||||
test('concurrent starts acquire one session and record the exact package version', async () => {
|
||||
process.env.SERVE_SIM_VERSION = '0.13.3';
|
||||
process.env.AGENT_DEVICE_VERSION = '0.16.0';
|
||||
process.env.ARGENT_VERSION = '1.0.0';
|
||||
const results: unknown[] = [];
|
||||
const starts = [0, 1].map(() =>
|
||||
startAsync(boot).then(
|
||||
@@ -174,12 +301,48 @@ test('concurrent starts acquire one session and record the exact package version
|
||||
expect((await readLauncherStateAsync(boot))?.launcherVersion).toBe(
|
||||
require('../../package.json').version
|
||||
);
|
||||
expect((await readLauncherStateAsync(boot))?.serveSimVersion).toBe('0.13.3');
|
||||
expect(jest.mocked(serveSim.waitForServeSimReadyAsync).mock.calls).toHaveLength(1);
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
status: 'claimed',
|
||||
serveSimVersion: '0.13.3',
|
||||
agentDeviceVersion: '0.16.0',
|
||||
argentVersion: '1.0.0',
|
||||
});
|
||||
await stopAsync(boot);
|
||||
await Promise.all(starts);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
});
|
||||
|
||||
test.each([undefined, 'latest', '^0.13'])(
|
||||
'start does not claim a resolved version for selector %s',
|
||||
async version => {
|
||||
if (version === undefined) {
|
||||
delete process.env.SERVE_SIM_VERSION;
|
||||
} else {
|
||||
process.env.SERVE_SIM_VERSION = version;
|
||||
}
|
||||
if (version === undefined) {
|
||||
delete process.env.AGENT_DEVICE_VERSION;
|
||||
delete process.env.ARGENT_VERSION;
|
||||
} else {
|
||||
process.env.AGENT_DEVICE_VERSION = version;
|
||||
process.env.ARGENT_VERSION = version;
|
||||
}
|
||||
const starting = startAsync(boot);
|
||||
active.push(starting);
|
||||
await status('ready');
|
||||
const state = await readLauncherStateAsync(boot);
|
||||
expect(state?.serveSimVersion).toBeUndefined();
|
||||
expect(state?.agentDeviceVersion).toBeUndefined();
|
||||
expect(state?.argentVersion).toBeUndefined();
|
||||
await stopAsync(boot);
|
||||
await starting;
|
||||
}
|
||||
);
|
||||
|
||||
test('stop before start makes late startup a resource-free disabled no-op', async () => {
|
||||
expect(await stopAsync(boot)).toBe(0);
|
||||
expect(await startAsync(boot)).toBe(0);
|
||||
@@ -473,3 +636,133 @@ test.each([
|
||||
await expect(result).rejects.toBe(failure);
|
||||
}
|
||||
});
|
||||
|
||||
test('start uses supplied runtime versions, ignores legacy pins and claims one boot snapshot', async () => {
|
||||
for (const name of [
|
||||
'SERVE_SIM_VERSION',
|
||||
'AGENT_DEVICE_VERSION',
|
||||
'ARGENT_VERSION',
|
||||
'EXPO_GO_SDK_VERSION',
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
process.env.SERVE_SIM_VERSION = '0.13.3';
|
||||
process.env.AGENT_DEVICE_VERSION = '0.21.36';
|
||||
process.env.ARGENT_VERSION = '0.32.0';
|
||||
const queries = jest.spyOn(commands, 'runOwnedAsync');
|
||||
const file = path.join(root, 'Library/Caches/expo-simulator-startup/prepared.json');
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const expoGo = { sdk: '58.0.0', url: 'https://example.test/prepared-expo-go.tar.gz' };
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tools: {
|
||||
'serve-sim': { version: '0.1.0' },
|
||||
'agent-device': { version: 'invalid legacy pin' },
|
||||
argent: { version: '0.32.0' },
|
||||
'expo-go': expoGo,
|
||||
},
|
||||
})
|
||||
);
|
||||
const starting = startAsync(boot);
|
||||
active.push(starting);
|
||||
await status('ready');
|
||||
expect(commands.start).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['@expo/[email protected]']),
|
||||
expect.anything()
|
||||
);
|
||||
expect(commands.start).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['[email protected]']),
|
||||
expect.anything()
|
||||
);
|
||||
expect(startExpoGoPreinstall).toHaveBeenCalledWith(expect.any(AbortSignal), expoGo, false);
|
||||
await fs.writeFile(file, JSON.stringify({ tools: { 'serve-sim': { version: '0.13.4' } } }));
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
serveSimVersion: '0.13.3',
|
||||
agentDeviceVersion: '0.21.36',
|
||||
argentVersion: '0.32.0',
|
||||
});
|
||||
expect(queries).not.toHaveBeenCalled();
|
||||
await stopAsync(boot);
|
||||
await starting;
|
||||
});
|
||||
|
||||
test('start honors a supplied Expo Go SDK over an older prepared selection', async () => {
|
||||
process.env.EXPO_GO_SDK_VERSION = '59.0.0';
|
||||
const file = path.join(root, 'Library/Caches/expo-simulator-startup/prepared.json');
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tools: {
|
||||
'expo-go': { sdk: '58.0.0', url: 'https://example.test/older-expo-go.tar.gz' },
|
||||
},
|
||||
})
|
||||
);
|
||||
const starting = startAsync(boot);
|
||||
active.push(starting);
|
||||
await status('ready');
|
||||
expect(startExpoGoPreinstall).toHaveBeenCalledWith(expect.any(AbortSignal), undefined, false);
|
||||
expect(process.env.EXPO_GO_SDK_VERSION).toBe('59.0.0');
|
||||
await stopAsync(boot);
|
||||
await starting;
|
||||
});
|
||||
|
||||
test('invalid legacy CLI metadata does not prevent runtime startup', async () => {
|
||||
const file = path.join(root, 'Library/Caches/expo-simulator-startup/prepared.json');
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, JSON.stringify({ tools: { 'serve-sim': { version: 'latest' } } }));
|
||||
const starting = startAsync(boot);
|
||||
active.push(starting);
|
||||
await status('ready');
|
||||
expect(commands.start).toHaveBeenCalled();
|
||||
await stopAsync(boot);
|
||||
await starting;
|
||||
});
|
||||
|
||||
test('warmup refreshes archive metadata while runtime overrides ignore legacy CLI pins', async () => {
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue({
|
||||
preinstallOnDeviceAsync: jest.fn(async () => {}),
|
||||
claimAsync: jest.fn(async udid => ({
|
||||
udid,
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '1',
|
||||
})),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
});
|
||||
const file = path.join(root, 'Library/Caches/expo-simulator-startup/prepared.json');
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, JSON.stringify({ tools: { 'serve-sim': { version: '0.13.3' } } }));
|
||||
process.env.SERVE_SIM_VERSION = '0.13.4';
|
||||
const fetching = jest.spyOn(prefetch, 'prefetchAsync').mockImplementation(async () => {
|
||||
expect(process.env.SERVE_SIM_VERSION).toBe('0.13.4');
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tools: {
|
||||
'serve-sim': { version: '0.1.0' },
|
||||
'expo-go': { sdk: '58.0.0', url: 'https://example.test/prepared-expo-go.tar.gz' },
|
||||
},
|
||||
})
|
||||
);
|
||||
return 0;
|
||||
});
|
||||
expect(await mainAsync(['warmup'])).toBe(0);
|
||||
expect(fetching).toHaveBeenCalledTimes(1);
|
||||
expect(commands.start).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['@expo/[email protected]']),
|
||||
expect.anything()
|
||||
);
|
||||
expect(startExpoGoPreinstall).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal),
|
||||
{ sdk: '58.0.0', url: 'https://example.test/prepared-expo-go.tar.gz' },
|
||||
true
|
||||
);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { commands } from '../utils/processes';
|
||||
import { until } from './helpers';
|
||||
|
||||
test.each(['running', 'exit'])(
|
||||
'cancellation drains the downloader after parent=%s',
|
||||
async mode => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'owned-download-'));
|
||||
const controller = new AbortController();
|
||||
const result = commands
|
||||
.runOwnedAsync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, 'fixtures/download-parent.cjs'), directory, mode],
|
||||
controller.signal
|
||||
)
|
||||
.catch(error => error);
|
||||
try {
|
||||
const pidFile = path.join(directory, 'child.pid');
|
||||
await until(() => fs.existsSync(pidFile));
|
||||
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
||||
controller.abort(new Error('cancelled'));
|
||||
expect(await result).toMatchObject({ message: 'cancelled' });
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
await sleepAsync(1100);
|
||||
expect(fs.existsSync(path.join(directory, 'late-write'))).toBe(false);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).not.toThrow();
|
||||
} finally {
|
||||
controller.abort();
|
||||
await result;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
10_000
|
||||
);
|
||||
|
||||
test('a failed executable preserves its spawn error', async () => {
|
||||
await expect(
|
||||
commands.runOwnedAsync('/missing-simulator-prefetch-command', [], new AbortController().signal)
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('already cancelled preparation starts no command', async () => {
|
||||
const start = jest.spyOn(commands, 'start');
|
||||
try {
|
||||
await expect(
|
||||
commands.runOwnedAsync('unused', [], AbortSignal.abort(new Error('cancelled')))
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
agentDevicePackageSpec,
|
||||
argentPackageSpec,
|
||||
getConfiguredToolVersions,
|
||||
serveSimPackageSpec,
|
||||
} from '../utils/packageSpecs';
|
||||
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
beforeEach(() => {
|
||||
environment = { ...process.env };
|
||||
delete process.env.SERVE_SIM_VERSION;
|
||||
delete process.env.AGENT_DEVICE_VERSION;
|
||||
delete process.env.ARGENT_VERSION;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = environment;
|
||||
});
|
||||
|
||||
it('keeps standalone defaults without claiming a resolved version', () => {
|
||||
expect(serveSimPackageSpec()).toBe('@expo/serve-sim@latest');
|
||||
expect(agentDevicePackageSpec()).toBe('agent-device@latest');
|
||||
expect(argentPackageSpec()).toBe('@swmansion/argent@latest');
|
||||
expect(getConfiguredToolVersions()).toEqual({
|
||||
serveSimVersion: undefined,
|
||||
agentDeviceVersion: undefined,
|
||||
argentVersion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses and reports exact supplied versions, including prereleases and build metadata', () => {
|
||||
process.env.SERVE_SIM_VERSION = '0.13.4-beta.1+build.2';
|
||||
process.env.AGENT_DEVICE_VERSION = '0.21.36';
|
||||
process.env.ARGENT_VERSION = '0.32.0';
|
||||
expect(getConfiguredToolVersions()).toEqual({
|
||||
serveSimVersion: '0.13.4-beta.1+build.2',
|
||||
agentDeviceVersion: '0.21.36',
|
||||
argentVersion: '0.32.0',
|
||||
});
|
||||
expect(serveSimPackageSpec()).toBe('@expo/[email protected]+build.2');
|
||||
expect(agentDevicePackageSpec()).toBe('[email protected]');
|
||||
expect(argentPackageSpec()).toBe('@swmansion/[email protected]');
|
||||
});
|
||||
|
||||
it.each(['latest', 'next', '^0.13', 'v0.13.4', ' 1.2.3 ', '1.2.3+build.2 ', '01.2.3', '1.2.3-01'])(
|
||||
'passes %s through without claiming an exact version',
|
||||
requested => {
|
||||
process.env.SERVE_SIM_VERSION = requested;
|
||||
expect(serveSimPackageSpec()).toBe(`@expo/serve-sim@${requested}`);
|
||||
expect(getConfiguredToolVersions().serveSimVersion).toBeUndefined();
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,610 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { mainAsync } from '../main';
|
||||
import { commands } from '../utils/processes';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/processes');
|
||||
|
||||
const platform = process.platform;
|
||||
const expoGoUrl = 'https://example.test/Expo-Go-58.0.7.tar.gz';
|
||||
const olderExpoGoUrl = 'https://example.test/Expo-Go-57.0.9.tar.gz';
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(commands.runOwnedAsync).mockResolvedValue('1.0.0\n');
|
||||
jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
environment = { ...process.env };
|
||||
process.env.SERVE_SIM_VERSION = '0.13.3';
|
||||
process.env.AGENT_DEVICE_VERSION = '0.21.36';
|
||||
process.env.ARGENT_VERSION = '0.32.0';
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
for (const name of [
|
||||
'APPIUM_VERSION',
|
||||
'XCUITEST_VERSION',
|
||||
'APPIUM_HOME',
|
||||
'npm_config_cache',
|
||||
'EXPO_GO_SDK_VERSION',
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'prefetch-home-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('unexpected fetch'));
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const cacheDirectory = (name: string): string =>
|
||||
path.join(home, 'Library', 'Caches', 'expo-simulator-startup', name);
|
||||
|
||||
const readPreparedManifest = () =>
|
||||
JSON.parse(fs.readFileSync(cacheDirectory('prepared.json'), 'utf8'));
|
||||
|
||||
/** Serves the versions API and the archive bytes at `expoGoUrl`. */
|
||||
function serveExpoGo(archive: Response = new Response('expo go archive')): void {
|
||||
jest.mocked(fetch).mockImplementation(async url =>
|
||||
url === 'https://api.expo.dev/v2/versions'
|
||||
? Response.json({
|
||||
sdkVersions: {
|
||||
'20.0.0': { releaseNoteUrl: 'https://example.test/20' },
|
||||
'57.0.0': { releaseNoteUrl: 'https://example.test/57', iosClientUrl: olderExpoGoUrl },
|
||||
'58.0.0': { releaseNoteUrl: 'https://example.test/58', iosClientUrl: expoGoUrl },
|
||||
},
|
||||
})
|
||||
: archive
|
||||
);
|
||||
}
|
||||
|
||||
function pinAppium(): { appiumHome: string; appium: string; wda: string } {
|
||||
process.env.APPIUM_VERSION = '3.9.0';
|
||||
process.env.XCUITEST_VERSION = '12.22.8';
|
||||
const appiumHome = cacheDirectory('appium');
|
||||
return {
|
||||
appiumHome,
|
||||
appium: path.join(appiumHome, 'node_modules', '.bin', 'appium'),
|
||||
wda: path.join(appiumHome, 'preinstalled-wda'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeWdaBundle(wda: string): string {
|
||||
const runner = path.join(wda, 'WebDriverAgentRunner-Runner.app');
|
||||
fs.mkdirSync(runner, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(runner, 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleExecutable</key><string>Runner</string></dict></plist>'
|
||||
);
|
||||
fs.writeFileSync(path.join(runner, 'Runner'), 'executable', { mode: 0o755 });
|
||||
return runner;
|
||||
}
|
||||
|
||||
test.each([
|
||||
'empty runner',
|
||||
'missing metadata',
|
||||
'invalid metadata',
|
||||
'missing executable',
|
||||
'empty executable',
|
||||
'non-executable',
|
||||
'directory executable',
|
||||
'outside executable',
|
||||
])('prefetch rejects WDA with %s and publishes no manifest', async kind => {
|
||||
const { wda, appiumHome } = pinAppium();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (args.includes('download-wda')) {
|
||||
const runner = writeWdaBundle(wda);
|
||||
if (kind === 'empty runner') {
|
||||
fs.rmSync(runner, { recursive: true });
|
||||
fs.mkdirSync(runner);
|
||||
} else if (kind === 'missing metadata') {
|
||||
fs.rmSync(path.join(runner, 'Info.plist'));
|
||||
} else if (kind === 'missing executable') {
|
||||
fs.rmSync(path.join(runner, 'Runner'));
|
||||
} else if (kind === 'empty executable') {
|
||||
fs.writeFileSync(path.join(runner, 'Runner'), '');
|
||||
} else if (kind === 'non-executable') {
|
||||
fs.chmodSync(path.join(runner, 'Runner'), 0o644);
|
||||
} else if (kind === 'directory executable') {
|
||||
fs.rmSync(path.join(runner, 'Runner'));
|
||||
fs.mkdirSync(path.join(runner, 'Runner'));
|
||||
}
|
||||
}
|
||||
if (file === '/usr/bin/plutil') {
|
||||
if (kind === 'invalid metadata') {
|
||||
throw new Error('Invalid property list');
|
||||
}
|
||||
return kind === 'outside executable' ? '../outside' : 'Runner\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('Could not prefetch WebDriverAgent');
|
||||
expect(fs.existsSync(cacheDirectory('prepared.json'))).toBe(false);
|
||||
expect(fs.existsSync(appiumHome)).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch does not inspect, install or record runtime CLI packages', async () => {
|
||||
process.env.SERVE_SIM_VERSION = 'latest';
|
||||
process.env.AGENT_DEVICE_VERSION = '^0.21';
|
||||
process.env.ARGENT_VERSION = 'invalid runtime selection';
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(commands.runOwnedAsync).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(output).not.toHaveBeenCalled();
|
||||
expect(readPreparedManifest()).toEqual({ tools: {} });
|
||||
});
|
||||
|
||||
test('replaces old CLI metadata on rerun without removing incidental Bun caches', async () => {
|
||||
fs.mkdirSync(path.dirname(cacheDirectory('prepared.json')), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
cacheDirectory('prepared.json'),
|
||||
JSON.stringify({ tools: { 'serve-sim': { version: '0.13.3' } } })
|
||||
);
|
||||
const bun = path.join(home, '.bun/install/cache');
|
||||
fs.mkdirSync(bun, { recursive: true });
|
||||
fs.writeFileSync(path.join(bun, 'retained'), 'native-warmup package cache');
|
||||
await mainAsync(['prefetch']);
|
||||
expect(readPreparedManifest()).toEqual({ tools: {} });
|
||||
expect(fs.readFileSync(path.join(bun, 'retained'), 'utf8')).toBe('native-warmup package cache');
|
||||
});
|
||||
|
||||
test('invalidates an older manifest before work and publishes nothing after failure', async () => {
|
||||
await mainAsync(['prefetch']);
|
||||
pinAppium();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async () => {
|
||||
expect(fs.existsSync(cacheDirectory('prepared.json'))).toBe(false);
|
||||
throw new Error('package setup failed');
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('package setup failed');
|
||||
expect(fs.existsSync(cacheDirectory('prepared.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('removes an incomplete manifest if its write fails', async () => {
|
||||
const manifestPath = cacheDirectory('prepared.json');
|
||||
jest.spyOn(fsPromises, 'writeFile').mockImplementationOnce(async file => {
|
||||
fs.writeFileSync(String(file), '{');
|
||||
throw new Error('manifest write failed');
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('manifest write failed');
|
||||
expect(fs.existsSync(manifestPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('removes the manifest when interrupted during its final write', async () => {
|
||||
const writeFile = fsPromises.writeFile;
|
||||
jest.spyOn(fsPromises, 'writeFile').mockImplementationOnce(async (...args) => {
|
||||
await writeFile(...args);
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('Simulator prefetch was interrupted.');
|
||||
expect(fs.existsSync(cacheDirectory('prepared.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch prepares Appium in a throwaway TMPDIR and removes it', async () => {
|
||||
const { wda } = pinAppium();
|
||||
const tmpdirs: string[] = [];
|
||||
const existed: boolean[] = [];
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (args.includes('download-wda')) {
|
||||
writeWdaBundle(wda);
|
||||
}
|
||||
tmpdirs.push(process.env.TMPDIR ?? '');
|
||||
existed.push(fs.existsSync(process.env.TMPDIR ?? ''));
|
||||
return file === '/usr/bin/plutil' ? 'Runner\n' : '1.0.0\n';
|
||||
});
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(existed).toEqual([true, true, true, true]);
|
||||
expect(new Set(tmpdirs).size).toBe(1);
|
||||
expect(path.dirname(tmpdirs[0])).toBe(os.tmpdir());
|
||||
expect(path.basename(tmpdirs[0])).toMatch(/^simulator-launcher-prefetch-/);
|
||||
expect(fs.existsSync(tmpdirs[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch stops after the first failed Appium step and removes its TMPDIR', async () => {
|
||||
pinAppium();
|
||||
let tmpdir = '';
|
||||
jest
|
||||
.mocked(commands.runOwnedAsync)
|
||||
.mockImplementationOnce(async () => {
|
||||
tmpdir = process.env.TMPDIR ?? '';
|
||||
return '0.13.3\n';
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('bunx exited with 1.'), { code: 1 }));
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
/Could not prefetch xcuitest@12\.22\.8: bunx exited with 1\./
|
||||
);
|
||||
expect(commands.runOwnedAsync).toHaveBeenCalledTimes(2);
|
||||
expect(tmpdir).toMatch(/simulator-launcher-prefetch-/);
|
||||
expect(fs.existsSync(tmpdir)).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch reports a timed-out Appium step', async () => {
|
||||
pinAppium();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort());
|
||||
jest
|
||||
.mocked(commands.runOwnedAsync)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })
|
||||
);
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
/Could not prefetch appium@3\.9\.0 within 300 seconds\./
|
||||
);
|
||||
expect(commands.runOwnedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each(['SIGINT', 'SIGTERM'] as const)(
|
||||
'standalone prefetch drains before failing on %s',
|
||||
async event => {
|
||||
pinAppium();
|
||||
let tmpdir = '';
|
||||
const listeners = process.listenerCount(event);
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (_file, _args, signal) => {
|
||||
tmpdir = process.env.TMPDIR!;
|
||||
return await new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
);
|
||||
});
|
||||
const result = mainAsync(['prefetch']).catch(error => error);
|
||||
await until(() => !!tmpdir);
|
||||
expect(fs.existsSync(tmpdir)).toBe(true);
|
||||
process.emit(event, event);
|
||||
expect(await result).toMatchObject({
|
||||
message: 'Could not prefetch [email protected]: Simulator prefetch was interrupted.',
|
||||
});
|
||||
expect(fs.existsSync(tmpdir)).toBe(false);
|
||||
expect(process.listenerCount(event)).toBe(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
test('interruption during final cleanup cannot report success', async () => {
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const rm = fsPromises.rm.bind(fsPromises);
|
||||
let tmpdir = '';
|
||||
jest.spyOn(fsPromises, 'rm').mockImplementation(async (target, options) => {
|
||||
if (String(target).includes('simulator-launcher-prefetch-')) {
|
||||
tmpdir = String(target);
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await rm(target, options);
|
||||
});
|
||||
const result = mainAsync(['prefetch']).catch(error => error);
|
||||
try {
|
||||
await entered.promise;
|
||||
expect(fs.existsSync(tmpdir)).toBe(true);
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
release.resolve();
|
||||
expect(await result).toMatchObject({ message: 'Simulator prefetch was interrupted.' });
|
||||
expect(fs.existsSync(tmpdir)).toBe(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await result;
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])('prefetch restores its environment after failure=%s', async fail => {
|
||||
const { wda } = pinAppium();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (args.includes('download-wda')) {
|
||||
writeWdaBundle(wda);
|
||||
}
|
||||
return file === '/usr/bin/plutil' ? 'Runner\n' : '';
|
||||
});
|
||||
process.env.TMPDIR = os.tmpdir();
|
||||
process.env.APPIUM_HOME = '/previous/appium';
|
||||
delete process.env.npm_config_cache;
|
||||
if (fail) {
|
||||
jest.mocked(commands.runOwnedAsync).mockRejectedValueOnce(new Error('failed'));
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('failed');
|
||||
} else {
|
||||
await mainAsync(['prefetch']);
|
||||
}
|
||||
expect(process.env.TMPDIR).toBe(os.tmpdir());
|
||||
expect(process.env.APPIUM_HOME).toBe('/previous/appium');
|
||||
expect(process.env.npm_config_cache).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prefetch bounds archive validation and extraction with the step signal', async () => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
serveExpoGo();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, _args, signal) => {
|
||||
if (file === '/usr/bin/gzip') {
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
throw new Error('extractor cancelled');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow('extractor cancelled');
|
||||
expect(fs.existsSync(cacheDirectory('expo-go'))).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch rejects extra arguments before any native work', async () => {
|
||||
await expect(mainAsync(['prefetch', 'x'])).rejects.toThrow(/Usage: .*\|prefetch>/);
|
||||
expect(commands.runOwnedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('prefetch skips Expo Go and Appium when they are turned off and removes older files', async () => {
|
||||
for (const name of ['expo-go', 'appium']) {
|
||||
fs.mkdirSync(cacheDirectory(name), { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheDirectory(name), 'old'), 'old');
|
||||
}
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
'Skipping Expo Go: EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0.\n'
|
||||
);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
'Skipping Appium: APPIUM_VERSION and XCUITEST_VERSION are not set.\n'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(path.dirname(cacheDirectory('appium')))).toEqual(['prepared.json']);
|
||||
});
|
||||
|
||||
test('prefetch stores the selected Expo Go archive', async () => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
serveExpoGo();
|
||||
const expoGo = cacheDirectory('expo-go');
|
||||
fs.mkdirSync(expoGo, { recursive: true });
|
||||
fs.writeFileSync(path.join(expoGo, 'stale.tar.gz'), 'stale');
|
||||
let tmpdir = '';
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
tmpdir = process.env.TMPDIR ?? '';
|
||||
if (file === '/usr/bin/tar') {
|
||||
fs.writeFileSync(path.join(args[3], 'Info.plist'), '<plist/>');
|
||||
}
|
||||
return '1.0.0\n';
|
||||
});
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
const file = path.join(expoGo, `${createHash('sha256').update(expoGoUrl).digest('hex')}.tar.gz`);
|
||||
expect(fs.readdirSync(expoGo)).toEqual([path.basename(file)]);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe('expo go archive');
|
||||
expect(jest.mocked(commands.runOwnedAsync).mock.calls).toEqual([
|
||||
['/usr/bin/gzip', ['-t', file], expect.any(AbortSignal)],
|
||||
[
|
||||
'/usr/bin/tar',
|
||||
['-xzf', file, '-C', path.join(tmpdir, 'ExpoGo.app')],
|
||||
expect.any(AbortSignal),
|
||||
],
|
||||
]);
|
||||
expect(path.basename(tmpdir)).toMatch(/^simulator-launcher-prefetch-/);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
`Prefetched Expo Go (SDK 58.0.0 from ${expoGoUrl}).\n`
|
||||
);
|
||||
expect(output).not.toHaveBeenCalled();
|
||||
expect(readPreparedManifest().tools['expo-go']).toEqual({ sdk: '58.0.0', url: expoGoUrl });
|
||||
});
|
||||
|
||||
test.each(['archive HTTP 500', 'gzip -t fails', 'no Info.plist'])(
|
||||
'prefetch removes the Expo Go cache after %s',
|
||||
async kind => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
serveExpoGo(kind === 'archive HTTP 500' ? new Response('', { status: 500 }) : undefined);
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/gzip' && kind === 'gzip -t fails') {
|
||||
throw Object.assign(new Error('/usr/bin/gzip exited with 1.'), { code: 1 });
|
||||
}
|
||||
if (file === '/usr/bin/tar' && kind !== 'no Info.plist') {
|
||||
fs.writeFileSync(path.join(args[3], 'Info.plist'), '<plist/>');
|
||||
}
|
||||
return '1.0.0\n';
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(/Could not prefetch Expo Go: /);
|
||||
expect(fs.existsSync(cacheDirectory('expo-go'))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
test('prefetch prepares the pinned Appium home and writes the shared manifest last', async () => {
|
||||
const { appiumHome, appium, wda } = pinAppium();
|
||||
let tmpdir = '';
|
||||
const seen: { APPIUM_HOME?: string; npm_config_cache?: string; prepared: boolean }[] = [];
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
tmpdir = process.env.TMPDIR ?? '';
|
||||
seen.push({
|
||||
APPIUM_HOME: process.env.APPIUM_HOME,
|
||||
npm_config_cache: process.env.npm_config_cache,
|
||||
prepared: fs.existsSync(cacheDirectory('prepared.json')),
|
||||
});
|
||||
if (file === 'npm') {
|
||||
fs.mkdirSync(path.dirname(appium), { recursive: true });
|
||||
fs.writeFileSync(appium, '');
|
||||
}
|
||||
if (args.includes('download-wda')) {
|
||||
writeWdaBundle(wda);
|
||||
}
|
||||
return file === '/usr/bin/plutil' ? 'Runner\n' : '1.0.0\n';
|
||||
});
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(jest.mocked(commands.runOwnedAsync).mock.calls).toEqual([
|
||||
[
|
||||
'npm',
|
||||
['install', '--no-audit', '--save-exact', '--prefix', appiumHome, '[email protected]'],
|
||||
expect.any(AbortSignal),
|
||||
],
|
||||
[appium, ['driver', 'install', '[email protected]'], expect.any(AbortSignal)],
|
||||
[
|
||||
appium,
|
||||
[
|
||||
'driver',
|
||||
'run',
|
||||
'xcuitest',
|
||||
'download-wda',
|
||||
'--',
|
||||
'--outdir',
|
||||
wda,
|
||||
'--platform',
|
||||
'iOS',
|
||||
'--kind',
|
||||
'sim',
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
],
|
||||
[
|
||||
'/usr/bin/plutil',
|
||||
[
|
||||
'-extract',
|
||||
'CFBundleExecutable',
|
||||
'raw',
|
||||
'-expect',
|
||||
'string',
|
||||
'-o',
|
||||
'-',
|
||||
path.join(wda, 'WebDriverAgentRunner-Runner.app', 'Info.plist'),
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
],
|
||||
]);
|
||||
const npmCache = path.join(tmpdir, 'npm-cache');
|
||||
expect(seen).toEqual([
|
||||
...Array(4).fill({ APPIUM_HOME: appiumHome, npm_config_cache: npmCache, prepared: false }),
|
||||
]);
|
||||
expect(readPreparedManifest().tools.appium).toEqual({
|
||||
version: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: appiumHome,
|
||||
});
|
||||
expect(fs.existsSync(path.join(appiumHome, 'prepared.json'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(appiumHome, 'package.json'), 'utf8')).toBe(
|
||||
'{"name":"eas-appium-home","private":true}\n'
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['only APPIUM_VERSION', { APPIUM_VERSION: '3.9.0' }],
|
||||
['only XCUITEST_VERSION', { XCUITEST_VERSION: '12.22.8' }],
|
||||
['APPIUM_VERSION=latest', { APPIUM_VERSION: 'latest', XCUITEST_VERSION: '12.22.8' }],
|
||||
['XCUITEST_VERSION=^12', { APPIUM_VERSION: '3.9.0', XCUITEST_VERSION: '^12' }],
|
||||
['APPIUM_VERSION=v3.9.0', { APPIUM_VERSION: 'v3.9.0', XCUITEST_VERSION: '12.22.8' }],
|
||||
[
|
||||
'XCUITEST_VERSION=12.22.8 || latest',
|
||||
{ APPIUM_VERSION: '3.9.0', XCUITEST_VERSION: '12.22.8 || latest' },
|
||||
],
|
||||
])('prefetch rejects %s before any command', async (_name, pins) => {
|
||||
Object.assign(process.env, pins);
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
'Could not prefetch Appium: set all of APPIUM_VERSION, XCUITEST_VERSION to exact versions (x.y.z), or none of them.'
|
||||
);
|
||||
expect(commands.runOwnedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('prefetch removes the Appium home after a failed driver install', async () => {
|
||||
const { appiumHome, appium } = pinAppium();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (file === appium && args[1] === 'install') {
|
||||
throw Object.assign(new Error(`${appium} exited with 1.`), { code: 1 });
|
||||
}
|
||||
return '1.0.0\n';
|
||||
});
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
/Could not prefetch xcuitest@12\.22\.8: .*appium exited with 1\./
|
||||
);
|
||||
expect(fs.existsSync(appiumHome)).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch removes the Appium home when download-wda leaves no runner', async () => {
|
||||
const { appiumHome } = pinAppium();
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(/Could not prefetch WebDriverAgent: /);
|
||||
expect(fs.existsSync(appiumHome)).toBe(false);
|
||||
});
|
||||
|
||||
test('prefetch stores Expo Go and prepares Appium without Bun tool pins', async () => {
|
||||
for (const name of [
|
||||
'SERVE_SIM_VERSION',
|
||||
'AGENT_DEVICE_VERSION',
|
||||
'ARGENT_VERSION',
|
||||
'EXPO_SIMULATOR_PREINSTALL_EXPO_GO',
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
serveExpoGo();
|
||||
const { appiumHome, appium, wda } = pinAppium();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
fs.writeFileSync(path.join(args[3], 'Info.plist'), '<plist/>');
|
||||
}
|
||||
if (file === 'npm') {
|
||||
fs.mkdirSync(path.dirname(appium), { recursive: true });
|
||||
fs.writeFileSync(appium, '');
|
||||
}
|
||||
if (args.includes('download-wda')) {
|
||||
writeWdaBundle(wda);
|
||||
}
|
||||
return file === '/usr/bin/plutil' ? 'Runner\n' : '1.0.0\n';
|
||||
});
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(jest.mocked(commands.runOwnedAsync).mock.calls.map(([file]) => file)).not.toContain(
|
||||
'bunx'
|
||||
);
|
||||
expect(fs.readdirSync(cacheDirectory('expo-go'))).toHaveLength(1);
|
||||
expect(readPreparedManifest()).toEqual({
|
||||
tools: {
|
||||
appium: { version: '3.9.0', xcuitest: '12.22.8', home: appiumHome },
|
||||
'expo-go': { sdk: '58.0.0', url: expoGoUrl },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('prefetch stores the Expo Go of EXPO_GO_SDK_VERSION, as start selects it', async () => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
process.env.EXPO_GO_SDK_VERSION = '57.0.0';
|
||||
serveExpoGo();
|
||||
jest.mocked(commands.runOwnedAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
fs.writeFileSync(path.join(args[3], 'Info.plist'), '<plist/>');
|
||||
}
|
||||
return '1.0.0\n';
|
||||
});
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
const hash = createHash('sha256').update(olderExpoGoUrl).digest('hex');
|
||||
expect(fs.readdirSync(cacheDirectory('expo-go'))).toEqual([`${hash}.tar.gz`]);
|
||||
expect(fetch).toHaveBeenLastCalledWith(olderExpoGoUrl, expect.anything());
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
`Prefetched Expo Go (SDK 57.0.0 from ${olderExpoGoUrl}).\n`
|
||||
);
|
||||
});
|
||||
|
||||
test.each(['latest', '58', '^58.0.0', 'v58.0.0'])(
|
||||
'prefetch ignores EXPO_GO_SDK_VERSION=%s when Expo Go is disabled',
|
||||
async pin => {
|
||||
process.env.EXPO_GO_SDK_VERSION = pin;
|
||||
expect(await mainAsync(['prefetch'])).toBe(0);
|
||||
expect(commands.runOwnedAsync).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['latest', '58', '^58.0.0', 'v58.0.0'])(
|
||||
'prefetch rejects EXPO_GO_SDK_VERSION=%s before any command or download',
|
||||
async pin => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
process.env.EXPO_GO_SDK_VERSION = pin;
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
'Could not prefetch Expo Go: set EXPO_GO_SDK_VERSION to an exact version (x.y.z), or leave it unset.'
|
||||
);
|
||||
expect(commands.runOwnedAsync).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
['does not list', '62.0.0'],
|
||||
['lists no Simulator archive for', '20.0.0'],
|
||||
])('prefetch fails when the versions API %s EXPO_GO_SDK_VERSION', async (_, pin) => {
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
process.env.EXPO_GO_SDK_VERSION = pin;
|
||||
serveExpoGo();
|
||||
await expect(mainAsync(['prefetch'])).rejects.toThrow(
|
||||
`Could not prefetch Expo Go: No Expo Go Simulator archive is available for SDK ${pin}.`
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(cacheDirectory('expo-go'))).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPreparedExpoGoAsync } from '../utils/preparedExpoGo';
|
||||
|
||||
let root: string;
|
||||
let file: string;
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
beforeEach(async () => {
|
||||
environment = { ...process.env };
|
||||
delete process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO;
|
||||
delete process.env.EXPO_GO_SDK_VERSION;
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'prepared-expo-go-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
file = path.join(root, 'Library/Caches/expo-simulator-startup/prepared.json');
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps standalone startup when no manifest exists', async () => {
|
||||
expect(await readPreparedExpoGoAsync()).toBeUndefined();
|
||||
});
|
||||
it('uses Expo Go metadata while ignoring legacy CLI pins and unrelated Appium data', async () => {
|
||||
const expoGo = { sdk: '58.0.0', url: 'https://example.test/expo-go.tar.gz' };
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tools: {
|
||||
'serve-sim': { version: 'obsolete' },
|
||||
'agent-device': null,
|
||||
argent: { malformed: true },
|
||||
'expo-go': expoGo,
|
||||
appium: { unknownFutureFormat: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
process.env.SERVE_SIM_VERSION = '0.13.4';
|
||||
expect(await readPreparedExpoGoAsync()).toEqual(expoGo);
|
||||
expect(process.env.SERVE_SIM_VERSION).toBe('0.13.4');
|
||||
});
|
||||
it.each([
|
||||
'not json',
|
||||
'{}',
|
||||
'{"tools":null}',
|
||||
JSON.stringify({ tools: { 'serve-sim': { version: 'latest' } } }),
|
||||
JSON.stringify({ tools: { 'expo-go': { sdk: '58.0.0', url: 'http://example.test/go.tar.gz' } } }),
|
||||
])('treats missing or invalid optional Expo Go metadata as a cache miss (%s)', async contents => {
|
||||
await fs.writeFile(file, contents);
|
||||
expect(await readPreparedExpoGoAsync()).toBeUndefined();
|
||||
});
|
||||
it('does not read metadata when Expo Go preinstallation is disabled', async () => {
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
const read = jest.spyOn(fs, 'readFile');
|
||||
expect(await readPreparedExpoGoAsync()).toBeUndefined();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['58.0.0', '59.0.0'])(
|
||||
'uses prepared archive metadata only when it matches supplied SDK %s',
|
||||
async sdk => {
|
||||
const prepared = { sdk: '58.0.0', url: 'https://example.test/expo-go.tar.gz' };
|
||||
await fs.writeFile(file, JSON.stringify({ tools: { 'expo-go': prepared } }));
|
||||
process.env.EXPO_GO_SDK_VERSION = sdk;
|
||||
expect(await readPreparedExpoGoAsync()).toEqual(sdk === prepared.sdk ? prepared : undefined);
|
||||
}
|
||||
);
|
||||
@@ -3,6 +3,8 @@ import { type ChildProcess } from 'node:child_process';
|
||||
import { createWarmSimulatorSession } from '../commands/start';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { readPreparedExpoGoAsync } from '../utils/preparedExpoGo';
|
||||
import { getConfiguredToolVersions } from '../utils/packageSpecs';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/expoGo');
|
||||
jest.mock('../utils/preparedExpoGo');
|
||||
jest.mock('../utils/packageSpecs');
|
||||
jest.mock('../utils/argent');
|
||||
jest.mock('../utils/processes');
|
||||
jest.mock('../utils/serveSim');
|
||||
@@ -70,6 +74,12 @@ function mockDefaultExpoGoInstall(install: Promise<void>) {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(readPreparedExpoGoAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(getConfiguredToolVersions).mockReturnValue({
|
||||
serveSimVersion: '0.13.3',
|
||||
agentDeviceVersion: '0.21.36',
|
||||
argentVersion: '0.32.0',
|
||||
});
|
||||
env = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo,iPhone 18';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/fixture/agent';
|
||||
@@ -116,6 +126,70 @@ afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('warmup waits for both devices, both installs and tools after default readiness', async () => {
|
||||
const secondaryBoot = deferred();
|
||||
const install = deferred();
|
||||
const tool = deferred();
|
||||
jest.mocked(waitForSimulatorBootAsync).mockReturnValue(secondaryBoot.promise);
|
||||
const preinstall = mockDefaultExpoGoInstall(install.promise);
|
||||
preinstall.claimAsync.mockImplementation(async () => ({ udid: 'DUO' }) as never);
|
||||
jest.mocked(createArgentTool).mockReturnValue({
|
||||
name: 'argent',
|
||||
startAsync: () => tool.promise,
|
||||
stopAsync: async () => {},
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
let done = false;
|
||||
const finishing = session.waitForWarmupAsync().then(() => {
|
||||
done = true;
|
||||
});
|
||||
try {
|
||||
install.resolve();
|
||||
tool.resolve();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(done).toBe(false);
|
||||
secondaryBoot.resolve();
|
||||
await finishing;
|
||||
expect(preinstall.preinstallOnDeviceAsync.mock.calls.map(([udid]) => udid)).toEqual([
|
||||
'DUO',
|
||||
'PHONE18',
|
||||
]);
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('PHONE18');
|
||||
} finally {
|
||||
install.resolve();
|
||||
secondaryBoot.resolve();
|
||||
tool.resolve();
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('warmup completion rejects an Expo Go install that runtime preparation only warns about', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
try {
|
||||
await expect(session.waitForWarmupAsync()).rejects.toThrow(
|
||||
'Expo Go preparation did not finish'
|
||||
);
|
||||
} finally {
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('warmup completion fails when the secondary Simulator cannot boot', async () => {
|
||||
jest.mocked(waitForSimulatorBootAsync).mockRejectedValue(new Error('secondary boot failed'));
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
try {
|
||||
await expect(session.waitForWarmupAsync()).rejects.toThrow('secondary boot failed');
|
||||
} finally {
|
||||
await session.stopAsync();
|
||||
}
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18');
|
||||
});
|
||||
|
||||
it('creates a stoppable owner before any native work and does not start after stop', async () => {
|
||||
const session = createSession();
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
@@ -131,6 +205,9 @@ it('stop joins pending device selection without starting a late server', async (
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync();
|
||||
void preparing.catch(() => {});
|
||||
await until(() =>
|
||||
jest.mocked(commands.runAsync).mock.calls.some(([, args]) => args.includes('devices'))
|
||||
);
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
@@ -590,3 +667,23 @@ it('warns on ordinary adopted-device apsd failure without failing the claimed se
|
||||
'Could not disable apsd on OTHER; continuing'
|
||||
);
|
||||
});
|
||||
|
||||
it('stop joins a pending manifest read and never starts a late native process', async () => {
|
||||
const metadata = deferred<undefined>();
|
||||
jest.mocked(readPreparedExpoGoAsync).mockReturnValue(metadata.promise);
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync();
|
||||
void preparing.catch(() => {});
|
||||
await until(() => jest.mocked(readPreparedExpoGoAsync).mock.calls.length > 0);
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
metadata.resolve(undefined);
|
||||
await stopping;
|
||||
await expect(preparing).rejects.toThrow();
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
expect(startServeSim).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
downloadExpoGoAsync,
|
||||
expoGoArchiveCacheDirectory,
|
||||
expoGoArchiveCachePath,
|
||||
extractCheckedExpoGoAsync,
|
||||
selectExpoGoAsync,
|
||||
} from '../utils/expoGo';
|
||||
import { commands } from '../utils/processes';
|
||||
|
||||
const PREFETCH_TIMEOUT_MS = 300_000;
|
||||
|
||||
type PreparedTool =
|
||||
| { version: string; home: string; xcuitest: string }
|
||||
| { sdk: string; url: string };
|
||||
|
||||
/** Store the selected Expo Go archive and pinned Appium/XCUITest/WDA files without booting devices. */
|
||||
export async function prefetchAsync(signal?: AbortSignal): Promise<number> {
|
||||
signal?.throwIfAborted();
|
||||
const [appiumVersion = '', xcuitestVersion = ''] =
|
||||
readPins('Appium', ['APPIUM_VERSION', 'XCUITEST_VERSION']) ?? [];
|
||||
// selectExpoGoAsync reads the pin itself. This check fails a bad value before any command or
|
||||
// download.
|
||||
if (process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO !== '0') {
|
||||
readPins('Expo Go', ['EXPO_GO_SDK_VERSION']);
|
||||
}
|
||||
const manifestPath = path.join(
|
||||
os.homedir(),
|
||||
'Library',
|
||||
'Caches',
|
||||
'expo-simulator-startup',
|
||||
'prepared.json'
|
||||
);
|
||||
const manifest: { tools: Record<string, PreparedTool> } = {
|
||||
tools: {},
|
||||
};
|
||||
// A new preparation attempt must not advertise an earlier set of versions.
|
||||
await rm(manifestPath, { force: true });
|
||||
const tmpdir = await mkdtemp(path.join(os.tmpdir(), 'simulator-launcher-prefetch-'));
|
||||
const environment = {
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
APPIUM_HOME: process.env.APPIUM_HOME,
|
||||
npm_config_cache: process.env.npm_config_cache,
|
||||
};
|
||||
try {
|
||||
process.env.TMPDIR = tmpdir;
|
||||
const expoGo = await prefetchExpoGoAsync(tmpdir, signal);
|
||||
if (expoGo) {
|
||||
manifest.tools['expo-go'] = expoGo;
|
||||
}
|
||||
const appium = await prefetchAppiumAsync(tmpdir, appiumVersion, xcuitestVersion, signal);
|
||||
if (appium) {
|
||||
manifest.tools.appium = appium;
|
||||
}
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(environment)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
await rm(tmpdir, { recursive: true, force: true });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
signal?.throwIfAborted();
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
await rm(manifestPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function prefetchExpoGoAsync(
|
||||
tmpdir: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ sdk: string; url: string } | undefined> {
|
||||
// A skipped step leaves no older archive.
|
||||
const directory = expoGoArchiveCacheDirectory();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
if (process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO === '0') {
|
||||
process.stderr.write('Skipping Expo Go: EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0.\n');
|
||||
return;
|
||||
}
|
||||
let prepared: { sdk: string; url: string } | undefined;
|
||||
await prefetchStepAsync(
|
||||
'Expo Go',
|
||||
async signal => {
|
||||
// The same selection as start, so start finds this file while the release is current.
|
||||
const { sdk, url } = await selectExpoGoAsync(signal);
|
||||
const file = expoGoArchiveCachePath(url);
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await downloadExpoGoAsync(url, file, signal);
|
||||
await extractCheckedExpoGoAsync(
|
||||
file,
|
||||
path.join(tmpdir, 'ExpoGo.app'),
|
||||
signal,
|
||||
(file, args) => commands.runOwnedAsync(file, args, signal)
|
||||
);
|
||||
} catch (error) {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
prepared = { sdk, url };
|
||||
return `SDK ${sdk} from ${url}`;
|
||||
},
|
||||
signal
|
||||
);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function prefetchAppiumAsync(
|
||||
tmpdir: string,
|
||||
appiumVersion: string,
|
||||
xcuitestVersion: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ version: string; xcuitest: string; home: string } | undefined> {
|
||||
// Build-tools reads this home (startAppiumRemoteSession.ts). README: "Prepared files".
|
||||
const home = path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium');
|
||||
const appium = path.join(home, 'node_modules', '.bin', 'appium');
|
||||
const wda = path.join(home, 'preinstalled-wda');
|
||||
// A skipped step leaves no older home, so build-tools then installs as before.
|
||||
await rm(home, { recursive: true, force: true });
|
||||
if (!appiumVersion) {
|
||||
process.stderr.write('Skipping Appium: APPIUM_VERSION and XCUITEST_VERSION are not set.\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await mkdir(home, { recursive: true });
|
||||
// The same package.json as the job-time home in build-tools.
|
||||
await writeFile(
|
||||
path.join(home, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
Object.assign(process.env, {
|
||||
APPIUM_HOME: home,
|
||||
npm_config_cache: path.join(tmpdir, 'npm-cache'),
|
||||
});
|
||||
await prefetchStepAsync(
|
||||
`appium@${appiumVersion}`,
|
||||
async signal => {
|
||||
await commands.runOwnedAsync(
|
||||
'npm',
|
||||
['install', '--no-audit', '--save-exact', '--prefix', home, `appium@${appiumVersion}`],
|
||||
signal
|
||||
);
|
||||
},
|
||||
signal
|
||||
);
|
||||
await prefetchStepAsync(
|
||||
`xcuitest@${xcuitestVersion}`,
|
||||
async signal => {
|
||||
await commands.runOwnedAsync(
|
||||
appium,
|
||||
['driver', 'install', `xcuitest@${xcuitestVersion}`],
|
||||
signal
|
||||
);
|
||||
},
|
||||
signal
|
||||
);
|
||||
await prefetchStepAsync(
|
||||
'WebDriverAgent',
|
||||
async signal => {
|
||||
await commands.runOwnedAsync(
|
||||
appium,
|
||||
[
|
||||
'driver',
|
||||
'run',
|
||||
'xcuitest',
|
||||
'download-wda',
|
||||
'--',
|
||||
'--outdir',
|
||||
wda,
|
||||
'--platform',
|
||||
'iOS',
|
||||
'--kind',
|
||||
'sim',
|
||||
],
|
||||
signal
|
||||
);
|
||||
const runner = path.join(wda, 'WebDriverAgentRunner-Runner.app');
|
||||
if (!(await stat(runner)).isDirectory()) {
|
||||
throw new Error('WebDriverAgentRunner-Runner.app is not a directory.');
|
||||
}
|
||||
const metadata = path.join(runner, 'Info.plist');
|
||||
await access(metadata, constants.R_OK);
|
||||
const executable = (
|
||||
await commands.runOwnedAsync(
|
||||
'/usr/bin/plutil',
|
||||
['-extract', 'CFBundleExecutable', 'raw', '-expect', 'string', '-o', '-', metadata],
|
||||
signal
|
||||
)
|
||||
).trim();
|
||||
if (
|
||||
!executable ||
|
||||
path.basename(executable) !== executable ||
|
||||
executable === '.' ||
|
||||
executable === '..'
|
||||
) {
|
||||
throw new Error('WebDriverAgent has an invalid bundle executable.');
|
||||
}
|
||||
const binary = path.join(runner, executable);
|
||||
await access(binary, constants.R_OK | constants.X_OK);
|
||||
const binaryStat = await stat(binary);
|
||||
if (!binaryStat.isFile() || binaryStat.size === 0) {
|
||||
throw new Error('WebDriverAgent has an invalid bundle executable.');
|
||||
}
|
||||
},
|
||||
signal
|
||||
);
|
||||
return { version: appiumVersion, xcuitest: xcuitestVersion, home };
|
||||
} catch (error) {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the exact (x.y.z) versions in these variables, or undefined when none is set. */
|
||||
function readPins(name: string, variables: string[]): string[] | undefined {
|
||||
const versions = variables.map(variable => process.env[variable] ?? '');
|
||||
if (versions.every(version => !version)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!versions.every(version => /^\d+\.\d+\.\d+$/.test(version))) {
|
||||
throw new Error(
|
||||
variables.length === 1
|
||||
? `Could not prefetch ${name}: set ${variables[0]} to an exact version (x.y.z), or leave it unset.`
|
||||
: `Could not prefetch ${name}: set all of ${variables.join(', ')} to exact versions (x.y.z), or none of them.`
|
||||
);
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
/** Runs one step with its own timeout. Progress goes to stderr; stdout stays empty. */
|
||||
async function prefetchStepAsync(
|
||||
name: string,
|
||||
run: (signal: AbortSignal) => Promise<string | void>,
|
||||
parentSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
process.stderr.write(`Prefetching ${name}.\n`);
|
||||
const timeout = AbortSignal.timeout(PREFETCH_TIMEOUT_MS);
|
||||
const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
|
||||
let detail: string | void;
|
||||
try {
|
||||
detail = await run(signal);
|
||||
} catch (error) {
|
||||
if (timeout.aborted) {
|
||||
throw new Error(`Could not prefetch ${name} within ${PREFETCH_TIMEOUT_MS / 1000} seconds.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Could not prefetch ${name}: ${message}`, { cause: error });
|
||||
}
|
||||
process.stderr.write(`Prefetched ${name}${detail ? ` (${detail})` : ''}.\n`);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { once } from 'node:events';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { prefetchAsync } from './prefetch';
|
||||
import { createAgentDeviceTool } from '../utils/agentDevice';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { readPreparedExpoGoAsync } from '../utils/preparedExpoGo';
|
||||
import { getConfiguredToolVersions } from '../utils/packageSpecs';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
@@ -35,20 +38,22 @@ const LAUNCHER_VERSION: string = require('../../package.json').version;
|
||||
|
||||
type WarmSimulatorSession = {
|
||||
prepareAsync(): Promise<WarmSessionInfo>;
|
||||
waitForWarmupAsync(): Promise<void>;
|
||||
claimAsync(request: ClaimInput): Promise<{ device: SimulatorDevice }>;
|
||||
prepareToolsAsync(): Promise<{ preinstalledExpoGo?: PreinstalledApp; tools?: ToolServer }>;
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export async function startAsync(bootUuid: string): Promise<number> {
|
||||
export async function startAsync(bootUuid: string, warmup = false): Promise<number> {
|
||||
const initial = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status === 'disabled') {
|
||||
if (warmup) {
|
||||
throw new Error('Warmup requires a fresh boot; preparation is already disabled.');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (state) {
|
||||
throw new Error(
|
||||
'Simulator preparation has already been started or disabled for this VM boot.'
|
||||
);
|
||||
throw new Error('Simulator preparation has already been started or disabled for this boot.');
|
||||
}
|
||||
return {
|
||||
bootUuid,
|
||||
@@ -60,13 +65,19 @@ export async function startAsync(bootUuid: string): Promise<number> {
|
||||
if (initial.status === 'disabled') {
|
||||
return 0;
|
||||
}
|
||||
await runWarmSimulatorSessionAsync(bootUuid);
|
||||
await runWarmSimulatorSessionAsync(bootUuid, warmup);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise<void> {
|
||||
export async function runWarmSimulatorSessionAsync(
|
||||
bootUuid: string,
|
||||
warmup = false
|
||||
): Promise<void> {
|
||||
const shutdown = new AbortController();
|
||||
const signal = shutdown.signal;
|
||||
// This deadline bounds tool preparation; it is not a settling delay. Cleanup is always joined.
|
||||
const signal = warmup
|
||||
? AbortSignal.any([shutdown.signal, AbortSignal.timeout(600_000)])
|
||||
: shutdown.signal;
|
||||
const interruption = new Error(
|
||||
'Simulator preparation was interrupted; cleanup may still be running.'
|
||||
);
|
||||
@@ -100,6 +111,7 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise<vo
|
||||
}
|
||||
const session = createWarmSimulatorSession({
|
||||
signal,
|
||||
warmup,
|
||||
throwIfStoppingAsync,
|
||||
onPreparationFailure(error) {
|
||||
if (!signal.aborted) {
|
||||
@@ -199,33 +211,44 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise<vo
|
||||
try {
|
||||
try {
|
||||
if (process.env.EXPO_SIMULATOR_STARTUP !== '1') {
|
||||
if (warmup) {
|
||||
throw new Error('Warmup requires EXPO_SIMULATOR_STARTUP=1.');
|
||||
}
|
||||
process.stderr.write('EXPO_SIMULATOR_STARTUP is not 1; Simulator warmup is disabled.\n');
|
||||
requestShutdown();
|
||||
}
|
||||
await prepareDefaultDeviceAsync();
|
||||
await handleOneClaimAsync();
|
||||
while (!signal.aborted) {
|
||||
const state = requireOwnedState(await readLauncherStateAsync(bootUuid), 'claimed');
|
||||
if (state.prepareToolsRequested && !state.toolsReady) {
|
||||
const prepared = await session.prepareToolsAsync();
|
||||
await updateLauncherStateAsync(
|
||||
bootUuid,
|
||||
current => ({
|
||||
...requireOwnedState(current, 'claimed'),
|
||||
...prepared,
|
||||
toolsReady: true,
|
||||
}),
|
||||
signal
|
||||
);
|
||||
break;
|
||||
if (warmup) {
|
||||
await prefetchAsync(signal);
|
||||
// Keep the state starting: tool preparation is never available for a job claim.
|
||||
warm = await session.prepareAsync();
|
||||
await session.waitForWarmupAsync();
|
||||
await throwIfStoppingAsync();
|
||||
} else {
|
||||
await prepareDefaultDeviceAsync();
|
||||
await handleOneClaimAsync();
|
||||
while (!signal.aborted) {
|
||||
const state = requireOwnedState(await readLauncherStateAsync(bootUuid), 'claimed');
|
||||
if (state.prepareToolsRequested && !state.toolsReady) {
|
||||
const prepared = await session.prepareToolsAsync();
|
||||
await updateLauncherStateAsync(
|
||||
bootUuid,
|
||||
current => ({
|
||||
...requireOwnedState(current, 'claimed'),
|
||||
...prepared,
|
||||
toolsReady: true,
|
||||
}),
|
||||
signal
|
||||
);
|
||||
break;
|
||||
}
|
||||
await sleepAsync(100, undefined, { signal });
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
await once(signal, 'abort');
|
||||
}
|
||||
await sleepAsync(100, undefined, { signal });
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
await once(signal, 'abort');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
if (warmup || !signal.aborted) {
|
||||
sessionError ??= error;
|
||||
}
|
||||
}
|
||||
@@ -242,10 +265,12 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise<vo
|
||||
/** Own one orphan process and its tool/preinstall work before any preparation awaits. */
|
||||
export function createWarmSimulatorSession({
|
||||
signal: parentSignal,
|
||||
warmup = false,
|
||||
throwIfStoppingAsync,
|
||||
onPreparationFailure,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
warmup?: boolean;
|
||||
throwIfStoppingAsync(): Promise<void>;
|
||||
onPreparationFailure(error: unknown): void;
|
||||
}): WarmSimulatorSession {
|
||||
@@ -266,6 +291,7 @@ export function createWarmSimulatorSession({
|
||||
let expoGo: ReturnType<typeof startExpoGoPreinstall> | undefined;
|
||||
let selectedPreinstall: Promise<PreinstalledApp | undefined> = Promise.resolve(undefined);
|
||||
let launcherCachePriming: Promise<void> = Promise.resolve();
|
||||
let simulatorPreparation: Promise<void> = Promise.resolve();
|
||||
let toolRetirement: Promise<PromiseSettledResult<void>[]> = Promise.resolve([]);
|
||||
|
||||
/**
|
||||
@@ -289,6 +315,9 @@ export function createWarmSimulatorSession({
|
||||
const apsd = booted.then(() =>
|
||||
disableApsdAsync(udid, signal).catch(error => {
|
||||
signal.throwIfAborted();
|
||||
if (warmup) {
|
||||
throw error;
|
||||
}
|
||||
process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`);
|
||||
})
|
||||
);
|
||||
@@ -343,6 +372,9 @@ export function createWarmSimulatorSession({
|
||||
}
|
||||
|
||||
async function prepareDefaultAsync(): Promise<WarmSessionInfo> {
|
||||
signal.throwIfAborted();
|
||||
const versions = getConfiguredToolVersions();
|
||||
const preparedExpoGo = await readPreparedExpoGoAsync();
|
||||
signal.throwIfAborted();
|
||||
availableDevices = await listAvailableSimulatorsAsync(signal);
|
||||
signal.throwIfAborted();
|
||||
@@ -368,16 +400,17 @@ export function createWarmSimulatorSession({
|
||||
const ready = waitForServeSimReadyAsync(first.udid, server, signal);
|
||||
void ready.catch(() => {});
|
||||
if (process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO !== '0') {
|
||||
expoGo = startExpoGoPreinstall(signal);
|
||||
expoGo = startExpoGoPreinstall(signal, preparedExpoGo, warmup);
|
||||
}
|
||||
for (const tool of warmTools) {
|
||||
startedTools.set(tool, startTool(tool, first.udid, ready, signal, onPreparationFailure));
|
||||
}
|
||||
startSimulatorsSetupAsync(
|
||||
simulatorPreparation = startSimulatorsSetupAsync(
|
||||
warmDevices.map(device => device.udid),
|
||||
ready
|
||||
).catch(error => {
|
||||
if (!signal.aborted) {
|
||||
);
|
||||
void simulatorPreparation.catch(error => {
|
||||
if (!warmup && !signal.aborted) {
|
||||
process.stderr.write(`Could not finish Simulator warmup; continuing: ${error}\n`);
|
||||
}
|
||||
});
|
||||
@@ -389,6 +422,7 @@ export function createWarmSimulatorSession({
|
||||
throw new Error('The warm Simulator tool paths are not configured.');
|
||||
}
|
||||
info = {
|
||||
...versions,
|
||||
device: first,
|
||||
server: serverInfo,
|
||||
controllerPid: process.pid,
|
||||
@@ -403,6 +437,22 @@ export function createWarmSimulatorSession({
|
||||
async prepareAsync() {
|
||||
return await (preparation ??= prepareDefaultAsync());
|
||||
},
|
||||
async waitForWarmupAsync() {
|
||||
await preparation;
|
||||
await Promise.all([
|
||||
simulatorPreparation,
|
||||
...[...startedTools.values()].map(tool => tool.ready),
|
||||
]);
|
||||
if (expoGo) {
|
||||
// Every install has settled. Reuse claim's existing result checks and archive drain.
|
||||
for (const device of warmDevices) {
|
||||
if (!(await expoGo.claimAsync(device.udid))) {
|
||||
throw new Error(`Expo Go preparation did not finish on Simulator ${device.udid}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
async claimAsync(input) {
|
||||
if (request || stopping) {
|
||||
throw new Error('This warm Simulator session has already been claimed or is stopping.');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync, prepareToolsAsync } from './commands/claim';
|
||||
import { prefetchAsync } from './commands/prefetch';
|
||||
import { startAsync } from './commands/start';
|
||||
import { stopAsync } from './commands/stop';
|
||||
import { commands } from './utils/processes';
|
||||
@@ -13,14 +14,32 @@ export async function mainAsync(args = process.argv.slice(2)): Promise<number> {
|
||||
process.stdout.write(`${require('../package.json').version}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.length === 1 && args[0] === 'prefetch') {
|
||||
const shutdown = new AbortController();
|
||||
const stop = (): void => shutdown.abort(new Error('Simulator prefetch was interrupted.'));
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
try {
|
||||
return await prefetchAsync(shutdown.signal);
|
||||
} finally {
|
||||
process.off('SIGINT', stop);
|
||||
process.off('SIGTERM', stop);
|
||||
}
|
||||
}
|
||||
const [command, engine, requestedDevice] = args;
|
||||
if (
|
||||
command !== 'start' &&
|
||||
command !== 'warmup' &&
|
||||
command !== 'stop' &&
|
||||
command !== 'claim' &&
|
||||
command !== 'prepare-tools'
|
||||
) {
|
||||
throw new Error('Usage: simulator-launcher <start|stop|claim ENGINE [DEVICE]|prepare-tools>');
|
||||
throw new Error(
|
||||
'Usage: simulator-launcher <warmup|start|stop|claim ENGINE [DEVICE]|prepare-tools|prefetch>'
|
||||
);
|
||||
}
|
||||
if (command === 'warmup' && args.length !== 1) {
|
||||
throw new Error('Usage: simulator-launcher warmup');
|
||||
}
|
||||
const bootUuid = (
|
||||
await commands.runAsync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'])
|
||||
@@ -31,6 +50,8 @@ export async function mainAsync(args = process.argv.slice(2)): Promise<number> {
|
||||
);
|
||||
}
|
||||
switch (command) {
|
||||
case 'warmup':
|
||||
return await startAsync(bootUuid, true);
|
||||
case 'start':
|
||||
return await startAsync(bootUuid);
|
||||
case 'stop':
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { agentDevicePackageSpec } from './packageSpecs';
|
||||
import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync } from './processes';
|
||||
import { type WarmTool, untilAbortedAsync } from './tools';
|
||||
|
||||
const packageSpec = (): string => `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`;
|
||||
|
||||
/** `prepare ios-runner` builds and starts the runner and its daemon; it exits once both are up. */
|
||||
export function createAgentDeviceTool(): WarmTool {
|
||||
const stateDir = (process.env.AGENT_DEVICE_STATE_DIR ||= path.join(
|
||||
@@ -24,7 +23,7 @@ export function createAgentDeviceTool(): WarmTool {
|
||||
prepareProcess = commands.start(
|
||||
'bunx',
|
||||
[
|
||||
packageSpec(),
|
||||
agentDevicePackageSpec(),
|
||||
'prepare',
|
||||
'ios-runner',
|
||||
'--platform',
|
||||
@@ -56,7 +55,7 @@ export function createAgentDeviceTool(): WarmTool {
|
||||
await commands
|
||||
.runAsync(
|
||||
'bunx',
|
||||
[packageSpec(), 'daemon', 'stop', '--clean', '--state-dir', stateDir],
|
||||
[agentDevicePackageSpec(), 'daemon', 'stop', '--clean', '--state-dir', stateDir],
|
||||
AbortSignal.timeout(120_000)
|
||||
)
|
||||
.catch(error => errors.push(error));
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { argentPackageSpec } from './packageSpecs';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
@@ -12,8 +13,6 @@ import {
|
||||
} from './processes';
|
||||
import { type WarmTool } from './tools';
|
||||
|
||||
const packageSpec = (): string => `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`;
|
||||
|
||||
const ArgentStateSchema = z.object({
|
||||
pid: z.number().int().min(2),
|
||||
port: z.literal(3201),
|
||||
@@ -43,7 +42,7 @@ export function createArgentTool(onUnexpectedExit: (error: unknown) => void): Wa
|
||||
['disable', 'video-watermark'],
|
||||
]) {
|
||||
signal.throwIfAborted();
|
||||
await commands.runAsync('bunx', [packageSpec(), verb, flag], signal).catch(error => {
|
||||
await commands.runAsync('bunx', [argentPackageSpec(), verb, flag], signal).catch(error => {
|
||||
if (verb !== 'disable') {
|
||||
throw error;
|
||||
}
|
||||
@@ -57,7 +56,7 @@ export function createArgentTool(onUnexpectedExit: (error: unknown) => void): Wa
|
||||
serverProcess = commands.start(
|
||||
'bunx',
|
||||
[
|
||||
packageSpec(),
|
||||
argentPackageSpec(),
|
||||
'server',
|
||||
'start',
|
||||
'--host',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
@@ -6,6 +7,7 @@ import { Writable } from 'node:stream';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commands } from './processes';
|
||||
import { type PreparedExpoGo } from './preparedExpoGo';
|
||||
import { type PreinstalledApp } from './state';
|
||||
|
||||
const VERSIONS_URL = 'https://api.expo.dev/v2/versions';
|
||||
@@ -22,14 +24,22 @@ const VersionsSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Downloads the latest stable Expo Go once, for every warm device. Claim forbids later installs;
|
||||
* started native installs and the shared archive stay owned until claim or stop releases them.
|
||||
* Prepares the Expo Go of EXPO_GO_SDK_VERSION, or else the latest stable one, once for every warm
|
||||
* device, from the prefetched archive or a download. Claim forbids later installs; started native
|
||||
* installs and the shared archive stay owned until claim or stop releases them.
|
||||
*/
|
||||
export function startExpoGoPreinstall(signal: AbortSignal): {
|
||||
export function startExpoGoPreinstall(
|
||||
signal: AbortSignal,
|
||||
prepared?: PreparedExpoGo,
|
||||
warmup = false
|
||||
): {
|
||||
claimAsync(selectedUdid: string): Promise<PreinstalledApp | undefined>;
|
||||
preinstallOnDeviceAsync(udid: string, boot: Promise<unknown>, signal: AbortSignal): Promise<void>;
|
||||
stopAsync(selectedUdid: string): Promise<void>;
|
||||
} {
|
||||
// Tool preparation is finite and cancellable; runtime claims still drain native installs.
|
||||
const runAsync = async (file: string, args: string[]): Promise<string> =>
|
||||
warmup ? await commands.runOwnedAsync(file, args, signal) : await commands.runAsync(file, args);
|
||||
const downloadController = new AbortController();
|
||||
const downloadSignal = AbortSignal.any([
|
||||
signal,
|
||||
@@ -44,34 +54,33 @@ export function startExpoGoPreinstall(signal: AbortSignal): {
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const archive = (async () => {
|
||||
const directoryPath = await directory;
|
||||
const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal: downloadSignal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Expo Go versions returned HTTP ${response.status}.`);
|
||||
}
|
||||
const { sdkVersions } = VersionsSchema.parse(await response.json());
|
||||
// Same latest stable release policy as expo.dev/go (GoScene/utils.ts).
|
||||
const sdk = Object.keys(sdkVersions)
|
||||
.filter(sdk => /\b(\d*)\.0\.0/.test(sdk))
|
||||
.slice(-10)
|
||||
.reverse()
|
||||
.find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta);
|
||||
const url = sdk && sdkVersions[sdk].iosClientUrl;
|
||||
if (!url || new URL(url).protocol !== 'https:') {
|
||||
throw new Error('No stable Expo Go Simulator archive is available.');
|
||||
}
|
||||
const archive = await fetch(url, { headers: HEADERS, signal: downloadSignal });
|
||||
if (!archive.ok || !archive.body) {
|
||||
throw new Error(`Expo Go archive returned HTTP ${archive.status}.`);
|
||||
const { sdk, url } = prepared ?? (await selectExpoGoAsync(downloadSignal));
|
||||
// Only prefetch writes the cache. A miss or a bad archive continues with the download.
|
||||
const cachedArchivePath = expoGoArchiveCachePath(url);
|
||||
const cached = await fs
|
||||
.access(cachedArchivePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (cached) {
|
||||
// A claim or stop during the lookup ends the work, as the download fetch would.
|
||||
downloadSignal.throwIfAborted();
|
||||
process.stderr.write(`Using the prefetched Expo Go archive for SDK ${sdk}.\n`);
|
||||
const appPath = path.join(directoryPath, 'cached', 'ExpoGo.app');
|
||||
try {
|
||||
await extractCheckedExpoGoAsync(cachedArchivePath, appPath, downloadSignal, runAsync);
|
||||
return { appPath, sdk };
|
||||
} catch (error) {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
downloadSignal.throwIfAborted();
|
||||
process.stderr.write(`Could not use the prefetched Expo Go archive: ${error}\n`);
|
||||
}
|
||||
}
|
||||
const archivePath = path.join(directoryPath, 'expo-go.tar.gz');
|
||||
await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), {
|
||||
signal: downloadSignal,
|
||||
});
|
||||
downloadSignal.throwIfAborted();
|
||||
await downloadExpoGoAsync(url, archivePath, downloadSignal);
|
||||
// Wait for the owned extractor to close, including on bad archives and claim/stop.
|
||||
const appPath = path.join(directoryPath, 'ExpoGo.app');
|
||||
await fs.mkdir(appPath);
|
||||
await commands.runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]);
|
||||
await runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]);
|
||||
downloadSignal.throwIfAborted();
|
||||
await fs.access(path.join(appPath, 'Info.plist'));
|
||||
|
||||
@@ -93,10 +102,10 @@ export function startExpoGoPreinstall(signal: AbortSignal): {
|
||||
installSignal.throwIfAborted();
|
||||
// No await between checking the claim latch and registering the native install.
|
||||
process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${target}.\n`);
|
||||
installation = commands.runAsync('xcrun', ['simctl', 'install', target, appPath]).then(
|
||||
installation = runAsync('xcrun', ['simctl', 'install', target, appPath]).then(
|
||||
async () => {
|
||||
process.stderr.write('Expo Go preinstall finished.\n');
|
||||
const info = await readPreinstalledAppAsync(target, appPath).catch(error => {
|
||||
const info = await readPreinstalledAppAsync(target, appPath, runAsync).catch(error => {
|
||||
process.stderr.write(`Could not read the preinstalled Expo Go version: ${error}\n`);
|
||||
return undefined;
|
||||
});
|
||||
@@ -168,11 +177,92 @@ export function startExpoGoPreinstall(signal: AbortSignal): {
|
||||
};
|
||||
}
|
||||
|
||||
async function readPreinstalledAppAsync(udid: string, appPath: string): Promise<PreinstalledApp> {
|
||||
/**
|
||||
* Selects the Expo Go Simulator archive of EXPO_GO_SDK_VERSION, or else of the latest stable SDK.
|
||||
* The archive URL must be https.
|
||||
*/
|
||||
export async function selectExpoGoAsync(
|
||||
signal: AbortSignal
|
||||
): Promise<{ sdk: string; url: string }> {
|
||||
const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Expo Go versions returned HTTP ${response.status}.`);
|
||||
}
|
||||
const { sdkVersions } = VersionsSchema.parse(await response.json());
|
||||
// A pinned SDK selects its listed release. Otherwise, the same latest stable release policy as
|
||||
// expo.dev/go (GoScene/utils.ts).
|
||||
const pinned = process.env.EXPO_GO_SDK_VERSION || undefined;
|
||||
const sdk = pinned
|
||||
? Object.keys(sdkVersions).find(sdk => sdk === pinned)
|
||||
: Object.keys(sdkVersions)
|
||||
.filter(sdk => /\b(\d*)\.0\.0/.test(sdk))
|
||||
.slice(-10)
|
||||
.reverse()
|
||||
.find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta);
|
||||
const url = sdk && sdkVersions[sdk].iosClientUrl;
|
||||
if (!sdk || !url || new URL(url).protocol !== 'https:') {
|
||||
throw new Error(
|
||||
pinned
|
||||
? `No Expo Go Simulator archive is available for SDK ${pinned}.`
|
||||
: 'No stable Expo Go Simulator archive is available.'
|
||||
);
|
||||
}
|
||||
return { sdk, url };
|
||||
}
|
||||
|
||||
export async function downloadExpoGoAsync(
|
||||
url: string,
|
||||
archivePath: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const archive = await fetch(url, { headers: HEADERS, signal });
|
||||
if (!archive.ok || !archive.body) {
|
||||
throw new Error(`Expo Go archive returned HTTP ${archive.status}.`);
|
||||
}
|
||||
await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), { signal });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
|
||||
/** The directory of the archives that prefetch stores. README: "Prepared files". */
|
||||
export function expoGoArchiveCacheDirectory(): string {
|
||||
return path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'expo-go');
|
||||
}
|
||||
|
||||
/** The archive that prefetch stores for this URL. */
|
||||
export function expoGoArchiveCachePath(url: string): string {
|
||||
const name = `${createHash('sha256').update(url).digest('hex')}.tar.gz`;
|
||||
return path.join(expoGoArchiveCacheDirectory(), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a stored archive. gzip -t runs first because tar accepts some changed bytes.
|
||||
* Runtime callers drain both commands without a signal. Tool preparation supplies a runner
|
||||
* that stops the owned process group on cancellation before returning.
|
||||
*/
|
||||
export async function extractCheckedExpoGoAsync(
|
||||
archivePath: string,
|
||||
appPath: string,
|
||||
signal: AbortSignal,
|
||||
runAsync: typeof commands.runAsync = commands.runAsync
|
||||
): Promise<void> {
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
signal.throwIfAborted();
|
||||
await runAsync('/usr/bin/gzip', ['-t', archivePath]);
|
||||
signal.throwIfAborted();
|
||||
await runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]);
|
||||
signal.throwIfAborted();
|
||||
await fs.access(path.join(appPath, 'Info.plist'));
|
||||
}
|
||||
|
||||
async function readPreinstalledAppAsync(
|
||||
udid: string,
|
||||
appPath: string,
|
||||
runAsync = commands.runAsync
|
||||
): Promise<PreinstalledApp> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
const [bundleIdentifier, shortVersion, version] = await Promise.all(
|
||||
['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key =>
|
||||
(await commands.runAsync('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath])).trim()
|
||||
(await runAsync('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath])).trim()
|
||||
)
|
||||
);
|
||||
return { udid, bundleIdentifier, shortVersion, version };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PackageVersionSchema } from './state';
|
||||
|
||||
export const serveSimPackageSpec = (): string =>
|
||||
`@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`;
|
||||
|
||||
export const agentDevicePackageSpec = (): string =>
|
||||
`agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`;
|
||||
|
||||
export const argentPackageSpec = (): string =>
|
||||
`@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`;
|
||||
|
||||
/** Report exact caller-supplied versions. Tags/ranges remain unknown until the package runs. */
|
||||
export function getConfiguredToolVersions(): {
|
||||
serveSimVersion?: string;
|
||||
agentDeviceVersion?: string;
|
||||
argentVersion?: string;
|
||||
} {
|
||||
const exact = (value: string | undefined): string | undefined =>
|
||||
PackageVersionSchema.safeParse(value).success ? value : undefined;
|
||||
return {
|
||||
serveSimVersion: exact(process.env.SERVE_SIM_VERSION),
|
||||
agentDeviceVersion: exact(process.env.AGENT_DEVICE_VERSION),
|
||||
argentVersion: exact(process.env.ARGENT_VERSION),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PreparedExpoGoSchema = z.object({
|
||||
sdk: z.string().regex(/^\d+\.\d+\.\d+$/),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(url => new URL(url).protocol === 'https:'),
|
||||
});
|
||||
export type PreparedExpoGo = z.infer<typeof PreparedExpoGoSchema>;
|
||||
|
||||
/** Optional archive metadata. Legacy CLI pins never select runtime package versions. */
|
||||
export async function readPreparedExpoGoAsync(): Promise<PreparedExpoGo | undefined> {
|
||||
if (process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO === '0') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const contents = await fs.readFile(
|
||||
path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'prepared.json'),
|
||||
'utf8'
|
||||
);
|
||||
const prepared = z
|
||||
.object({ tools: z.object({ 'expo-go': PreparedExpoGoSchema.optional() }) })
|
||||
.parse(JSON.parse(contents)).tools['expo-go'];
|
||||
const requested = process.env.EXPO_GO_SDK_VERSION;
|
||||
return requested && prepared?.sdk !== requested ? undefined : prepared;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,36 @@ export const commands = {
|
||||
async runAsync(file: string, args: string[], signal?: AbortSignal): Promise<string> {
|
||||
return await commands.start(file, args, { signal, captureStdout: true }).resultPromise;
|
||||
},
|
||||
/** Finite tool preparation: cancellation stops the whole owned group before returning. */
|
||||
async runOwnedAsync(file: string, args: string[], signal: AbortSignal): Promise<string> {
|
||||
signal.throwIfAborted();
|
||||
const handle = commands.start(file, args, { detached: true, captureStdout: true });
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): void => {
|
||||
stopping ??= terminateProcessGroupAsync(handle);
|
||||
void stopping.catch(() => {}); // Joined below, including when the child fails first.
|
||||
};
|
||||
signal.addEventListener('abort', stop, { once: true });
|
||||
if (signal.aborted) {
|
||||
stop();
|
||||
}
|
||||
try {
|
||||
return await handle.resultPromise;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', stop);
|
||||
// Also drain descendants after a successful or failed wrapper exit.
|
||||
if (handle.child.pid) {
|
||||
stop();
|
||||
await stopping?.catch(error => {
|
||||
commandCloseDrainError ||= new Error('Tool preparation cleanup could not be confirmed.', {
|
||||
cause: error,
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
},
|
||||
throwIfCommandCloseTimedOut(): void {
|
||||
if (commandCloseDrainError) {
|
||||
throw commandCloseDrainError;
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { serveSimPackageSpec } from './packageSpecs';
|
||||
import { type ChildProcessHandle, commands, isProcessOwnedByServiceAsync } from './processes';
|
||||
import { type WarmServer } from './state';
|
||||
|
||||
@@ -26,7 +27,7 @@ export function startServeSim(
|
||||
return commands.start(
|
||||
'bunx',
|
||||
[
|
||||
`@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`,
|
||||
serveSimPackageSpec(),
|
||||
...devices,
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
|
||||
const NonBlankStringSchema = z.string().regex(/\S/, 'Must not be blank.');
|
||||
// semver also accepts a leading v and whitespace; claims keep exact version inputs.
|
||||
export const PackageVersionSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
value => semver.valid(value) !== null && value.trim() === value && !value.startsWith('v')
|
||||
);
|
||||
|
||||
export const SimulatorDeviceSchema = z.object({
|
||||
udid: NonBlankStringSchema,
|
||||
name: NonBlankStringSchema,
|
||||
@@ -31,6 +39,9 @@ export const PreinstalledAppSchema = z.object({
|
||||
version: z.string(),
|
||||
});
|
||||
export const WarmSessionInfoSchema = z.object({
|
||||
serveSimVersion: PackageVersionSchema.optional(),
|
||||
agentDeviceVersion: PackageVersionSchema.optional(),
|
||||
argentVersion: PackageVersionSchema.optional(),
|
||||
// The default before claim, then the device selected for the job.
|
||||
device: SimulatorDeviceSchema,
|
||||
server: WarmServerSchema,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/vm-tools
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/vm-tools",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"description": "VM startup script loader",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<key>LimitLoadToSessionType</key><string>Aqua</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><false/>
|
||||
<key>ProcessType</key><string>Interactive</string>
|
||||
<key>ExitTimeOut</key><integer>150</integer>
|
||||
<key>WorkingDirectory</key><string>__HOME__</string>
|
||||
<key>StandardOutPath</key><string>__HOME__/Library/Logs/vm-tools.log</string>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "27.5.0",
|
||||
"version": "27.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "27.5.0",
|
||||
"@expo/build-tools": "27.6.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/template-file": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/template-file": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:27.5.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:27.6.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/downloader": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.5.0"
|
||||
"@expo/template-file": "npm:27.5.0"
|
||||
"@expo/turtle-spawn": "npm:27.5.0"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/template-file": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@expo/xcpretty": "npm:^4.4.7"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:27.5.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:27.6.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:27.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:27.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:27.5.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:27.5.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:27.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:27.5.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:27.6.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3128,8 +3128,10 @@ __metadata:
|
||||
dependencies:
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
"@types/semver": "npm:^7.5.8"
|
||||
"@vercel/ncc": "npm:^0.38.1"
|
||||
jest: "npm:^29.7.0"
|
||||
semver: "npm:^7.6.2"
|
||||
ts-jest: "npm:^29.4.12"
|
||||
typescript: "npm:^6.0.3"
|
||||
zod: "npm:^4.3.5"
|
||||
@@ -3147,12 +3149,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:27.5.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:27.6.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3178,7 +3180,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:27.5.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:27.6.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3201,11 +3203,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:27.5.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:27.6.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3236,17 +3238,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:27.5.0"
|
||||
"@expo/build-tools": "npm:27.6.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.5.0"
|
||||
"@expo/template-file": "npm:27.5.0"
|
||||
"@expo/turtle-spawn": "npm:27.5.0"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/template-file": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10139,7 +10141,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:27.5.0"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10723,12 +10725,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:27.5.0"
|
||||
"@expo/build-tools": "npm:27.6.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:27.5.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10782,12 +10784,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:27.5.0"
|
||||
"@expo/eas-json": "npm:27.5.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/eas-json": "npm:27.6.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:27.5.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10799,7 +10801,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.5.0"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user