Compare commits

...
8 Commits
Author SHA1 Message Date
Expo CI 4a109648a6 v24.7.0 2026-09-16 22:13:30 +00:00
Gabe Debes ea139e207c [eas-cli] point preview links at the expo.dev page and expose the preview api url (#4352) 2026-09-16 15:07:49 -07:00
Gabe Debes 7b3c8cf245 [build-tools] report the expo.dev preview page url and the preview api url (#4353) 2026-09-16 14:31:37 -07:00
Gabe Debes efbe0bbdff [build-tools] Launch simulator packages with the resolved package manager (#4343)
* [build-tools] Launch simulator packages with the resolved package manager

Serve-sim, device-hub, and Argent were hardcoded to npx or bunx. Resolve the manager the same way other steps do so EAS_FALLBACK_PACKAGE_MANAGER can switch them off the npm audit path.

* [build-tools] Override the simulator package manager instead of using the lockfile fallback

EAS_FALLBACK_PACKAGE_MANAGER only applies when no lockfile is found, so a worker yarn.lock would keep npx. EAS_OVERRIDE_PACKAGE_MANAGER wins when set. Preview still defaults to npx and Argent to bunx.

* [build-tools] Honor fallback as well as override for simulator package exec

Override still wins when set. If it is unset, EAS_FALLBACK_PACKAGE_MANAGER selects the runner so serve-sim can use bunx without a lockfile-based resolve.

* [build-tools] Add changelog for simulator package manager env

* [build-tools] Format package manager changes

* [build-tools] Honor the simulator package manager for agent-device and Appium

Install these session packages through the same env override/fallback as serve-sim, instead of hardcoding bun add --global and npm install.

* [build-tools] Sort Appium session test imports for lint

* [build-tools] Keep simulator process comments concrete

* [build-tools] Cover simulator package install and agent-device fallback
2026-09-15 16:19:51 -07:00
Gabe Debes 8f645d8870 [build-tools] Default serve-sim iOS previews to H.264 (#4416)
* fix(build-tools): default serve-sim iOS previews to H.264

* changelog
2026-09-15 13:34:00 -07:00
Stanisław Chmiela 97007d17cc [build-tools] Use maestro-runner --parallel execution for shards (#4407)
`maestro-runner` supports sharding!

Runner parallel execution: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/executor/parallel.go
2026-09-15 19:09:52 +02:00
Expo CI 0dbaa34a8b bump STAGING and PRODUCTION to 24.6.0 in cli-versions.json 2026-09-15 16:21:45 +00:00
Expo CI 2fc537f9a7 update CHANGELOG.md for v24.6.0 2026-09-15 16:17:00 +00:00
31 changed files with 1243 additions and 348 deletions

No files matched your search

+14
View File
@@ -8,6 +8,20 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.5.0",
"PRODUCTION": "24.5.0"
"STAGING": "24.6.0",
"PRODUCTION": "24.6.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.6.0",
"version": "24.7.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.5.0",
"version": "24.7.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects sharding with maestro-runner', async () => {
it('passes shards to maestro-runner as parallel devices', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
@@ -52,7 +52,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -115,7 +119,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
});
@@ -1,9 +1,38 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { Sentry } from '../../../sentry';
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
import {
startAgentDeviceDaemonAsync,
stopAgentDeviceEventCollectionSafelyAsync,
} from '../startAgentDeviceRemoteSession';
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
async function writeDaemonEntry(cwd: string): Promise<string> {
const daemonPath = path.join(
cwd,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'daemon.js'
);
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
await fs.promises.writeFile(daemonPath, '');
return daemonPath;
}
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
beforeEach(() => {
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
it('reports an unexpected stop failure without rejecting', async () => {
const error = new Error('stop failed');
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
stopAgentDeviceEventCollectionSafelyAsync({
@@ -37,3 +65,161 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
);
});
});
describe(startAgentDeviceDaemonAsync, () => {
const stopAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
stopAsync.mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
command: string,
args: string[],
options?: { cwd?: string }
) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
if (typeof options?.cwd === 'string' && command !== 'git') {
await writeDaemonEntry(options.cwd);
}
return { stdout: '' };
}) as never);
});
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
await handle.stopAsync();
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', 'agent-device@latest'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'--branch',
'v1.2.3',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ env: {}, logger })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--production'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageSpec: '[email protected]',
packageManager: 'bun',
bunVersion: '1.3.11',
}),
})
);
});
it('clones latest and reports unknown bun when diagnostics fail', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
throw new Error('bun missing');
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ logger })
);
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', '--omit=dev'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageVersion: 'latest',
packageManager: 'npm',
bunVersion: 'unknown',
}),
})
);
});
});
@@ -1,10 +1,16 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
import {
installAppiumAsync,
resolveAppium3VersionSpec,
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
@@ -15,6 +21,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
jest.mock('../../utils/remoteDeviceRunSession', () => ({
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -84,3 +91,63 @@ describe(resolveAppiumDeviceAsync, () => {
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
});
});
describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
}
return { stdout: '' };
}) as never);
});
it('installs Appium with npm by default', async () => {
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: { EXISTING: 'value' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--no-audit', 'appium@^3'],
expect.objectContaining({
cwd: result.appiumHome,
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
})
);
expect(spawn).toHaveBeenCalledWith(
result.appiumBinPath,
['driver', 'install', 'xcuitest'],
expect.objectContaining({ env: result.appiumEnv, logger })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const result = await installAppiumAsync({
versionSpec: '3.5.0',
driverName: 'uiautomator2',
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: result.appiumHome })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
});
@@ -88,10 +88,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://argent-abc.tunnel.example.com',
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -179,7 +181,8 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bun x resolve it.')
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -32,7 +32,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
stopAsync: jest.fn(),
};
async function start(signal?: AbortSignal): Promise<void> {
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
@@ -44,7 +44,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -54,7 +55,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});
@@ -64,7 +66,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
@@ -89,7 +92,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
remoteConfig: {
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
@@ -108,6 +108,9 @@ function buildMaestroArgs({
}
case 'maestro-runner': {
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
);
}
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
@@ -18,6 +18,12 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import {
@@ -122,7 +128,9 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -132,7 +140,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -183,6 +192,74 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
await fs.promises.writeFile(
path.join(installDir, 'package.json'),
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
);
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
},
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
packageManager,
bunVersion,
},
}
);
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
@@ -205,61 +282,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
}
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
@@ -276,14 +298,17 @@ async function startAgentDeviceDaemonFromGitAsync({
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
logger.info(`Installing agent-device dependencies with ${install.command}.`);
await spawn(install.command, install.args, {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
@@ -346,11 +371,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
installDir,
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
@@ -360,11 +383,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -21,6 +21,11 @@ import {
} from '../utils/localEgressSession';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
@@ -153,7 +158,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -198,7 +204,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
return versionSpec;
}
type AppiumDevice = {
export type AppiumDevice = {
platformName: 'iOS' | 'Android';
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
@@ -247,7 +253,7 @@ export async function resolveAppiumDeviceAsync({
}
}
async function installAppiumAsync({
export async function installAppiumAsync({
versionSpec,
driverName,
env,
@@ -265,9 +271,14 @@ async function installAppiumAsync({
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec}.`);
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
@@ -19,6 +19,11 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
@@ -113,41 +118,44 @@ export function createStartArgentRemoteSessionBuildFunction(
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
// Keep Argent itself in foreground mode under the detached bun process. This preserves
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
],
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
});
if (argentServer.pid === undefined) {
@@ -216,7 +224,9 @@ export function createStartArgentRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -226,7 +236,8 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -306,7 +317,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bun x resolve it.`
`Continuing and letting the package manager resolve it.`
);
return;
}
@@ -61,7 +61,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${webPreview.previewUrl}`);
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
try {
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -70,7 +70,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
remoteConfig: {
previewUrl: webPreview.previewUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
@@ -25,6 +25,7 @@ import {
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
@@ -130,7 +131,6 @@ describe(createServeSimArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'@expo/serve-sim@latest',
'--port',
'4321',
@@ -140,7 +140,7 @@ describe(createServeSimArgs, () => {
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
'960',
'--mjpeg-quality',
@@ -169,17 +169,15 @@ describe(createServeSimArgs, () => {
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
'@expo/serve-sim@next'
);
});
});
@@ -191,7 +189,6 @@ describe(createExpoDeviceHubArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
@@ -219,10 +216,9 @@ describe(createExpoDeviceHubArgs, () => {
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
);
});
});
@@ -309,6 +305,11 @@ describe(startNgrokTunnelAsync, () => {
})
);
expect(tunnel.url).toBe('https://web-preview.example.test');
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
})
);
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -419,9 +420,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -442,7 +443,38 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
expect(preview.apiUrl).toBe('https://preview.example.test');
});
it.each([
[{}, 'https://expo.dev'],
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
});
it('points the preview URL at the website page for the tunnel', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
const previewId = preview.previewPageUrl.split('/').at(-1);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
);
});
// serve-sim is always launched with --require-token, so a missing token means it is running
@@ -505,9 +537,18 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
expect(args).toEqual([
'--yes',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -538,6 +579,73 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
packageVersion: '4.5.6',
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -15,6 +15,11 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
@@ -22,6 +27,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
// Pinned while H.264 is known to stall at larger encode sizes without recovering.
// Passing this explicitly also overrides serve-sim's own default H.264 cap, so raising it
// loses that guard too.
const SERVE_SIM_MAX_DIMENSION = '960';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
@@ -549,7 +557,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
}
try {
// spawnDetached creates a dedicated process group. Signaling the group also
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
// terminates npx/bun descendants instead of leaving the actual daemon alive.
process.kill(-pid, 'SIGTERM');
} catch {
try {
@@ -612,6 +620,14 @@ export function spawnDetached({
};
}
export function websiteOrigin(env: BuildStepEnv): string {
return env.EXPO_LOCAL
? 'https://expo.test'
: env.EXPO_STAGING
? 'https://staging.expo.dev'
: 'https://expo.dev';
}
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
if (!origin) {
@@ -639,15 +655,16 @@ export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
frameAncestorArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
frameAncestorArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
@@ -657,7 +674,7 @@ export function createServeSimArgs({
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
SERVE_SIM_MAX_DIMENSION,
'--mjpeg-quality',
@@ -668,6 +685,7 @@ export function createServeSimArgs({
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...metricsCorsArgs,
...frameAncestorArgs,
];
}
@@ -681,7 +699,6 @@ export function createExpoDeviceHubArgs({
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
@@ -771,7 +788,8 @@ export async function waitForWebPreviewReadyAsync({
}
export type DeviceWebPreviewHandle = {
previewUrl: string;
previewPageUrl: string;
apiUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
@@ -802,11 +820,17 @@ async function startWebPreviewWithTunnelAsync(
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
createArgs(port, turnArgs)
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
command: previewExec.command,
args: previewExec.args,
env,
});
@@ -827,7 +851,11 @@ async function startWebPreviewWithTunnelAsync(
logger,
});
return {
previewUrl: tunnel.url,
previewPageUrl: new URL(
`/simulator-preview/${tunnel.subdomainId}`,
websiteOrigin(env)
).toString(),
apiUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
@@ -869,6 +897,7 @@ export async function startServeSimWithTunnelAsync(
}
): Promise<ServeSimPreviewHandle> {
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const frameAncestorArgs = ['--frame-ancestor', websiteOrigin(env)];
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
@@ -877,7 +906,7 @@ export async function startServeSimWithTunnelAsync(
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
createServeSimArgs({ port, turnArgs, metricsCorsArgs, frameAncestorArgs, packageVersion }),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
@@ -951,6 +980,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
@@ -969,7 +999,8 @@ export async function startNgrokTunnelAsync({
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
const subdomainId = randomBytes(16).toString('hex');
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
@@ -987,6 +1018,7 @@ export async function startNgrokTunnelAsync({
let stopped = false;
return {
url,
subdomainId,
stopAsync: async () => {
if (stopped) {
return;
@@ -9,6 +9,12 @@ import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolveConfiguredPackageManager,
resolveFallbackPackageManager,
resolveOverridePackageManager,
resolvePackageAdd,
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
});
});
describe(resolveFallbackPackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveFallbackPackageManager({})).toBeUndefined();
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveOverridePackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveOverridePackageManager({})).toBeUndefined();
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveConfiguredPackageManager, () => {
it('uses the unset default', () => {
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
});
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
expect(
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
).toBe(PackageManager.BUN);
});
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
expect(
resolveConfiguredPackageManager(
{
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
},
PackageManager.BUN
)
).toBe(PackageManager.NPM);
});
});
describe(resolvePackageExec, () => {
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
it('maps npm to npx --yes', () => {
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps bun to bun x', () => {
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
command: 'bun',
args: ['x', ...packageArgs],
});
});
it('maps yarn to npx --yes', () => {
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps pnpm to pnpm dlx', () => {
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
command: 'pnpm',
args: ['dlx', ...packageArgs],
});
});
});
describe(resolvePackageAdd, () => {
it('maps npm to npm install --no-audit', () => {
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
command: 'npm',
args: ['install', '--no-audit', 'appium@^3'],
});
});
it('maps bun to bun add', () => {
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
command: 'bun',
args: ['add', 'agent-device@latest'],
});
});
it('maps yarn to yarn add', () => {
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
command: 'yarn',
args: ['add', 'appium@^3'],
});
});
it('maps pnpm to pnpm add', () => {
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
command: 'pnpm',
args: ['add', 'appium@^3'],
});
});
});
describe(resolvePackageInstall, () => {
it('maps bun production install', () => {
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
command: 'bun',
args: ['install', '--production'],
});
});
it('maps npm production install without audit', () => {
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
command: 'npm',
args: ['install', '--no-audit', '--omit=dev'],
});
});
it('maps yarn and pnpm production installs', () => {
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
command: 'yarn',
args: ['install', '--production'],
});
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
command: 'pnpm',
args: ['install', '--prod'],
});
});
it('maps non-production installs', () => {
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
command: 'npm',
args: ['install', '--no-audit'],
});
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
command: 'bun',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
command: 'yarn',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
command: 'pnpm',
args: ['install'],
});
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
@@ -30,19 +30,100 @@ export function resolvePackageManager(
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
}
function parsePackageManagerEnvValue(
value: string | undefined,
envName: string,
errorCode: string
): PackageManager | undefined {
if (!value) {
return undefined;
}
const parsed = z.enum(PackageManager).safeParse(value);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
errorCode,
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
);
}
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_FALLBACK_PACKAGE_MANAGER,
'EAS_FALLBACK_PACKAGE_MANAGER',
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
);
}
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_OVERRIDE_PACKAGE_MANAGER,
'EAS_OVERRIDE_PACKAGE_MANAGER',
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
);
}
export function resolveConfiguredPackageManager(
env: BuildStepEnv,
whenUnset: PackageManager
): PackageManager {
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
}
export function resolvePackageExec(
manager: PackageManager,
args: string[]
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.BUN:
return { command: 'bun', args: ['x', ...args] };
case PackageManager.YARN:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['dlx', ...args] };
}
}
export function resolvePackageAdd(
manager: PackageManager,
packageSpec: string
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
case PackageManager.BUN:
return { command: 'bun', args: ['add', packageSpec] };
case PackageManager.YARN:
return { command: 'yarn', args: ['add', packageSpec] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['add', packageSpec] };
}
}
export function resolvePackageInstall(
manager: PackageManager,
{ production }: { production?: boolean } = {}
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return {
command: 'npm',
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
};
case PackageManager.BUN:
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.YARN:
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.PNPM:
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
}
return PackageManager.YARN;
}
/**
+133 -133
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -6680,6 +6680,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
@@ -33439,6 +33451,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
@@ -36300,6 +36324,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "toolsAuthToken",
"description": null,
@@ -78928,6 +78964,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewToken",
"description": "Session token gating the preview. Null when the preview runs ungated.",
@@ -93670,6 +93718,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.6.0",
"version": "24.7.0",
"description": "EAS command line tool",
"keywords": [
"cli",
+15 -5
View File
@@ -1102,6 +1102,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when a web preview is
@@ -4720,6 +4722,8 @@ export type AppiumRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when a web preview is
@@ -5147,6 +5151,8 @@ export type ArgentRunSessionRemoteConfig = {
* session does not use local egress.
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
toolsAuthToken?: Maybe<Scalars['String']['output']>;
toolsUrl: Scalars['String']['output'];
/** Session token gating the web preview. Null when the preview runs ungated. */
@@ -11214,6 +11220,8 @@ export type ServeSimRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
previewToken?: Maybe<Scalars['String']['output']>;
previewUrl: Scalars['String']['output'];
/** @deprecated Use previewUrl instead. */
@@ -13337,6 +13345,8 @@ export type WebPreviewOnlyRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
webPreviewUrl: Scalars['String']['output'];
};
@@ -16386,11 +16396,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
@@ -88,6 +88,7 @@ export const DeviceRunSessionQuery = {
agentDeviceRemoteSessionToken
webPreviewUrl
webPreviewToken
previewApiUrl
egressUrl
egressToken
egressFingerprint
@@ -102,6 +103,7 @@ export const DeviceRunSessionQuery = {
toolsAuthToken
webPreviewUrl
webPreviewToken
previewApiUrl
}
... on AppiumRunSessionRemoteConfig {
egressUrl
@@ -112,6 +114,7 @@ export const DeviceRunSessionQuery = {
capabilities
webPreviewUrl
webPreviewToken
previewApiUrl
}
... on ServeSimRunSessionRemoteConfig {
egressUrl
@@ -120,6 +123,7 @@ export const DeviceRunSessionQuery = {
egressPort
previewUrl
previewToken
previewApiUrl
}
... on WebPreviewOnlyRunSessionRemoteConfig {
egressUrl
@@ -128,6 +132,7 @@ export const DeviceRunSessionQuery = {
egressPort
previewUrl: webPreviewUrl
previewToken: webPreviewToken
previewApiUrl
}
}
turtleJobRun {
@@ -240,25 +240,39 @@ describe('simulator resource class flags', () => {
});
describe(formatPreviewUrl, () => {
it('appends the session token for a gated preview', () => {
expect(formatPreviewUrl('https://preview.example.test', 'tok-1')).toBe(
'https://preview.example.test/?token=tok-1'
);
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
const SERVER_URL = 'https://web-preview-abc.eas-simulator.ngrok.dev';
const pageConfig = {
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE_URL,
previewToken: 'tok-1',
previewApiUrl: SERVER_URL,
};
const serverConfig = {
__typename: 'ServeSimRunSessionRemoteConfig' as const,
previewUrl: SERVER_URL,
previewToken: 'tok-1',
};
it('puts the token in the fragment for the expo.dev page, where no request carries it', () => {
expect(formatPreviewUrl(pageConfig, PAGE_URL, 'tok-1')).toBe(`${PAGE_URL}#token=tok-1`);
});
it('puts the token on the query for a session that predates the expo.dev page', () => {
expect(formatPreviewUrl(serverConfig, SERVER_URL, 'tok-1')).toBe(`${SERVER_URL}/?token=tok-1`);
});
it('leaves the url alone when the preview is ungated', () => {
expect(formatPreviewUrl('https://preview.example.test', null)).toBe(
'https://preview.example.test'
);
expect(formatPreviewUrl('https://preview.example.test', undefined)).toBe(
'https://preview.example.test'
);
expect(formatPreviewUrl(pageConfig, PAGE_URL, null)).toBe(PAGE_URL);
expect(formatPreviewUrl(pageConfig, PAGE_URL, undefined)).toBe(PAGE_URL);
});
});
describe(sanitizeRemoteConfigForJson, () => {
const PREVIEW_URL = 'https://preview.example.test';
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
it('moves the token into the preview url and drops the standalone field', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'ServeSimRunSessionRemoteConfig' as const,
@@ -273,6 +287,22 @@ describe(sanitizeRemoteConfigForJson, () => {
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
});
it('puts the token on the api url query, for a caller that cannot send a header', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE_URL,
previewToken: 'tok-1',
previewApiUrl: PREVIEW_URL,
});
expect(sanitized).toEqual({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: `${PAGE_URL}#token=tok-1`,
previewApiUrl: `${PREVIEW_URL}/?token=tok-1`,
});
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
});
it('does the same for a controller session', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'ArgentRunSessionRemoteConfig' as const,
@@ -368,6 +398,48 @@ describe('gated preview links', () => {
}
});
it('prints the fragment link for every session the worker gave a preview api url', () => {
const PAGE = 'https://expo.dev/simulator-preview/abc';
const API = 'https://web-preview-abc.eas-simulator.ngrok.dev';
const sessions = [
{
__typename: 'ServeSimRunSessionRemoteConfig' as const,
previewUrl: PAGE,
previewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE,
previewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: PAGE,
webPreviewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'ArgentRunSessionRemoteConfig' as const,
toolsUrl: 'https://argent.example.test',
toolsAuthToken: 'argent-token',
webPreviewUrl: PAGE,
webPreviewToken: 'tok-1',
previewApiUrl: API,
},
{ ...iosAppiumConfig, webPreviewUrl: PAGE, webPreviewToken: 'tok-1', previewApiUrl: API },
];
for (const remoteConfig of sessions) {
const instructions = formatRemoteSessionInstructions(remoteConfig, 'env');
expect(instructions).toContain(`${PAGE}#token=tok-1`);
expect(instructions).not.toContain('?token=');
}
});
it('prints the plain url when the preview is ungated', () => {
const instructions = formatRemoteSessionInstructions(
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW },
+40 -13
View File
@@ -221,11 +221,29 @@ function getControllerEnvironmentVariables(
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
function previewUrlIsWebsitePage(remoteConfig: DeviceRunSessionRemoteConfig): boolean {
return remoteConfig.previewApiUrl != null;
}
/**
* Preview link for a session. A gated serve-sim needs the session token, and a browser cannot send
* a header on a page load, so it rides the query. serve-sim swaps it for a cookie on the first load.
* Preview link for a session. A gated preview needs the session token, and a browser cannot send
* a header on a page load. The expo.dev preview page reads it from the fragment, which no request
* carries; a preview server reads it from the query and swaps it for a cookie on the first load.
*/
export function formatPreviewUrl(url: string, token: string | null | undefined): string {
export function formatPreviewUrl(
remoteConfig: DeviceRunSessionRemoteConfig,
url: string,
token: string | null | undefined
): string {
if (!token || !previewUrlIsWebsitePage(remoteConfig)) {
return formatPreviewApiUrl(url, token);
}
const withToken = new URL(url);
withToken.hash = new URLSearchParams({ token }).toString();
return withToken.toString();
}
function formatPreviewApiUrl(url: string, token: string | null | undefined): string {
if (!token) {
return url;
}
@@ -235,9 +253,9 @@ export function formatPreviewUrl(url: string, token: string | null | undefined):
}
/**
* Remote config for `--json`. The preview URL carries the token and the standalone token field is
* dropped, so a consumer gets one URL that works rather than a bare URL that 401s next to a secret
* it has to know to combine.
* Remote config for `--json`. Both URLs carry the token and the standalone token field is dropped,
* so a consumer gets URLs that work rather than bare URLs that 401 next to a secret it has to know
* to combine.
*/
export function sanitizeRemoteConfigForJson(
remoteConfig: DeviceRunSessionRemoteConfig
@@ -246,7 +264,13 @@ export function sanitizeRemoteConfigForJson(
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig': {
const { previewToken, ...rest } = remoteConfig;
return { ...rest, previewUrl: formatPreviewUrl(remoteConfig.previewUrl, previewToken) };
return {
...rest,
previewUrl: formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, previewToken),
previewApiUrl: remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, previewToken)
: remoteConfig.previewApiUrl,
};
}
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
@@ -255,8 +279,11 @@ export function sanitizeRemoteConfigForJson(
return {
...rest,
webPreviewUrl: remoteConfig.webPreviewUrl
? formatPreviewUrl(remoteConfig.webPreviewUrl, webPreviewToken)
? formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, webPreviewToken)
: remoteConfig.webPreviewUrl,
previewApiUrl: remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, webPreviewToken)
: remoteConfig.previewApiUrl,
};
}
}
@@ -331,7 +358,7 @@ function formatControllerInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -369,7 +396,7 @@ function formatControllerInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -397,7 +424,7 @@ function formatControllerInstructions(
'',
'Open the simulator preview:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -406,13 +433,13 @@ function formatControllerInstructions(
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
case 'WebPreviewOnlyRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.5.0",
"version": "24.7.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.5.0",
"@expo/build-tools": "24.7.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.5.0",
"version": "24.7.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.5.0",
"@expo/build-tools": "24.7.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.5.0",
Generated
+3 -3
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.5.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.7.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2972,7 +2972,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.5.0"
"@expo/build-tools": "npm:24.7.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.5.0"
@@ -10415,7 +10415,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.5.0"
"@expo/build-tools": "npm:24.7.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"