Compare commits

..
Author SHA1 Message Date
Brent Vatne 4f95ca50d9 [eas-cli] Add simulator:simctl to set appearance and restart SpringBoard on remote iOS sessions 2026-10-17 13:49:01 -07:00
William Grosset 0199cd9331 [eas-cli] Add an agent guidance experiment to the Free plan build limit error (#4631) 2026-10-16 16:27:33 -07:00
William Grosset ffe6bda88d [eas-cli] Add experimentation client (#4558)
* [eas-cli] Add experimentation client

* [eas-cli] Document experimentation context order and override semantics

* [eas-cli] Take the account unit from the command instead of the app config

* [eas-cli] Skip experiments in CI

* [eas-cli] Make the GraphQL request timeout take effect

* [eas-cli] Document which experiment units are shared across surfaces

* [eas-cli] Test that non-timeout fetch errors pass through the timeout wrapper

* [eas-cli] Fetch the experimentation config lazily on first variant read

* [eas-cli] Cap the actor lookup in the experimentation context field
2026-10-16 15:22:39 -07:00
Gabe DebesandGPT-6.1-Sol 904859b824 [build-tools] Let serve-sim start the iOS Simulator for agent-device (#4544)
* [build-tools] Let serve-sim start the iOS Simulator for agent-device

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for agent-device startup

* [build-tools] Read agent-device app metadata from the artifact helper

* [build-tools] Pass the preview token in the startup regression

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-16 14:45:27 -07:00
Expo CI 3fd966bb9e bump STAGING and PRODUCTION to 25.1.1 in cli-versions.json 2026-10-16 21:44:06 +00:00
Expo CI aa9f5370d4 v25.1.1 2026-10-16 21:35:07 +00:00
Gabe Debes 60a0b483eb [ci] Generate build-tools GraphQL types before typechecking (#4663) 2026-10-16 23:17:55 +02:00
Szymon Dziedzic 070f15c633 [build-tools] Remove the GraphQL fallbacks for device run session jobs (#4638) 2026-10-16 23:00:28 +02:00
106 changed files with 5092 additions and 1390 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add experimentation client.
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
-1
View File
@@ -22,4 +22,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+6
View File
@@ -2,6 +2,12 @@
This is the log of notable changes to EAS CLI and related packages.
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.0.0",
"PRODUCTION": "25.0.0"
"STAGING": "25.1.1",
"PRODUCTION": "25.1.1"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.1.0",
"version": "25.1.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
+6
View File
@@ -1,5 +1,11 @@
# @expo/build-tools
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.1.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -36,22 +36,23 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "25.1.0",
"@expo/eas-build-job": "25.1.0",
"@expo/downloader": "25.1.1",
"@expo/eas-build-job": "25.1.1",
"@expo/env": "^0.4.0",
"@expo/logger": "25.1.0",
"@expo/logger": "25.1.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.0",
"@expo/template-file": "25.1.0",
"@expo/turtle-spawn": "25.1.0",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -14,6 +14,12 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -51,6 +57,13 @@ jest.mock('../../utils/localEgress', () => ({
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
...jest.requireActual('../../utils/localEgressGuard'),
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
reportLocalEgressGuardCoverageAsync: jest.fn(),
verifyLocalEgressGuardAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
@@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockImplementation(async () =>
(await readLocalEgressHandoffAsync())
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
: null
);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
@@ -70,7 +70,7 @@ const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs the session for a device that is already booted, with the app (if any) launched. */
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
@@ -94,7 +94,10 @@ async function runAsync(
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
@@ -124,13 +127,23 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
const host = {
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
};
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
try {
await options.application;
options.signal?.throwIfAborted();
} catch (error) {
await mockPreviewStopAsync();
throw error;
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -150,7 +163,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -289,14 +302,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let resolve!: (value: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -304,18 +317,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
return { promise, resolve, reject };
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal
) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
const tasks = createStartupTasks(logger, signal);
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
signal,
runtimePlatform:
'iosSimulatorUdid' in sessionDevice
? BuildRuntimePlatform.DARWIN
: BuildRuntimePlatform.LINUX,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
@@ -326,7 +347,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
});
}
@@ -336,7 +357,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
it('starts the daemon during the boot and the session host after it', async () => {
it('aborts parallel startup before host failure cleanup finishes', async () => {
const failure = new Error('guard refused startup');
const cleanup = deferred();
const notified = deferred();
let startupTasks!: StartupTasks;
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
options.onStartupError?.(failure);
notified.resolve();
await cleanup.promise;
throw failure;
});
const session = startSession(tasks => {
startupTasks = tasks;
return { iosSimulatorUdid: 'selected-udid' };
});
const rejected = expect(session).rejects.toBe(failure);
await notified.promise;
expect(startupTasks.signal.aborted).toBe(true);
expect(startupTasks.signal.reason).toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
cleanup.resolve();
await rejected;
});
it('starts the daemon during the Android boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -348,10 +393,237 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.LINUX,
timeoutMs: 60_000,
iosSimulatorUdid: undefined,
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
it('preserves a failed download when aborting daemon installation rejects first', async () => {
const failure = new Error('download failed: build not found');
const installStarted = deferred();
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
return await new Promise((_resolve, reject) => {
options!.signal!.addEventListener(
'abort',
() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
},
{ once: true }
);
installStarted.resolve();
});
}) as typeof spawn);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
return await new Promise((_resolve, reject) => {
signal!.addEventListener(
'abort',
() => {
setTimeout(() => reject(signal!.reason), 20);
},
{ once: true }
);
});
});
const applicationReady = Promise.resolve();
const session = startSession(tasks => {
void tasks.run('build download', async () => {
await installStarted.promise;
throw failure;
});
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
});
await expect(session).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('boots the host during download and releases startup only after preparation', async () => {
const hostReady = deferred();
const application = deferred<{
installAppPath: string;
launchAppIdentifier: string;
launchArgs: string[];
openUrl: string;
}>();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const release = jest.fn();
jest
.mocked(startDeviceSessionHostAsync)
.mockClear()
.mockImplementation(async (_ctx, options) => {
release(await options.application);
await hostReady.promise;
return host;
});
const applicationReady = application.promise;
const session = startSession({
iosSimulatorUdid: 'selected-udid',
application: applicationReady,
});
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(release).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
application.resolve(startup);
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: applicationReady,
iosSimulatorUdid: 'selected-udid',
timeoutMs: 60_000,
signal: expect.any(AbortSignal),
})
);
expect(release).toHaveBeenCalledWith(startup);
expect(host.openPreviewAsync).not.toHaveBeenCalled();
hostReady.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes the guard boot environment with the startup installation and launch', async () => {
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
};
await startSession({
iosSimulatorUdid: 'guarded-udid',
bootEnv,
application: Promise.resolve(startup),
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: expect.any(Promise),
bootEnv,
iosSimulatorUdid: 'guarded-udid',
})
);
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(hostOptions.bootEnv).toBe(bootEnv);
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
});
it('cleans up a host returned after cancellation without configuring the device', async () => {
const hostReady = deferred();
const controller = new AbortController();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
await hostReady.promise;
return host;
});
const applicationReady = Promise.resolve();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
hostReady.resolve();
await failure;
expect(host.openPreviewAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
const controller = new AbortController();
const appReady = deferred();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await flushAsync();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
appReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not report readiness when cancellation arrives during preview startup', async () => {
const controller = new AbortController();
const previewReady = deferred();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const preview = await host.openPreviewAsync({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
});
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
previewReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not start resources for an already cancelled session', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
const applicationReady = Promise.resolve();
await expect(
startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
)
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
@@ -361,10 +633,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
it('stops waiting for the Android boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -378,7 +651,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
it('cancels daemon installation and stops the Android host when the download fails', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -394,21 +667,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
it('stops the Android host, daemon and tunnel when the download fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
@@ -1,11 +1,20 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
@@ -15,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
jest.mock('../../utils/localEgress', () => ({
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn(),
}));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
@@ -43,7 +65,17 @@ const sessionEnv = {
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
let deviceLaunch: unknown;
function runDevicePreparationAsync(device: Device): Promise<unknown> {
return (
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
).then(async launch => {
deviceLaunch = launch;
return launch;
});
}
function deferred<T = void>(): {
promise: Promise<T>;
@@ -69,6 +101,7 @@ async function flushAsync(): Promise<void> {
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
@@ -103,22 +136,32 @@ function runStep(
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
{ inputs, outputs: {}, env: {}, signal } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { device, tasks }) => {
await tasks.untilAborted(runDevicePreparationAsync(device));
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
@@ -134,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
};
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
await runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
).toBeLessThan(
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
@@ -159,28 +211,45 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
const device = sessionDevice();
expect(device).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
application: expect.any(Promise),
});
await step;
expect(spawn).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
it('shuts down only the selected guarded Booted device before host startup', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
await runStep(BuildRuntimePlatform.DARWIN);
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
udid: 'SELECTED-UDID',
env: {},
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
env: {},
logger: expect.any(Object),
signal: expect.any(AbortSignal),
});
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
});
@@ -191,7 +260,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, sessionCtx);
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -215,6 +284,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
await runStep(
BuildRuntimePlatform.LINUX,
{ application_archive_url: 'https://example.test/app.tar.gz' },
undefined,
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
@@ -225,7 +295,96 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('waits for the download when the Simulator boots first', async () => {
it('fails before download or host startup when guarded device shutdown fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'shutdown failed'
);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
expect('iosSimulatorUdid' in device).toBe(true);
if (!('iosSimulatorUdid' in device)) {
return;
}
await hostReady.promise;
deviceLaunch = await device.application;
});
const step = runStep(BuildRuntimePlatform.DARWIN, {
device_identifier: 'iPhone 17',
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
hostReady.resolve();
await step;
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'missing bin/egress-guard-check'
);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('releases guard reporting and egress resources when host startup fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockRejectedValueOnce(new Error('guard check failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'guard check failed'
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('waits for the download before handing the app to the host', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
@@ -237,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('makes the session ready when the device boots if there is no app', async () => {
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
application: undefined,
});
});
it('boots the Android Emulator with the device inputs', async () => {
@@ -278,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'download failed'
);
expect(installBuildAsync).not.toHaveBeenCalled();
});
@@ -323,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
const ready = runDevicePreparationAsync(device);
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
await Promise.all([daemon, ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
await Promise.allSettled([daemon, ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
@@ -363,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('cancels and drains a pending download through the external signal', async () => {
const controller = new AbortController();
const download = mockStalledDownload();
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await failure;
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or download work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
selected.resolve();
await failure;
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('drains app preparation when the daemon fails', async () => {
const metadata = deferred<string>();
const metadataStarted = deferred();
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
metadataStarted.resolve();
return metadata.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
@@ -464,6 +670,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it.each([
['argument count', { launch_args: Array(257).fill('a') }],
['argument length', { launch_args: ['a'.repeat(9000)] }],
['NUL argument', { launch_args: ['a\u0000b'] }],
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
['NUL URL', { open_url: 'example:\u0000screen' }],
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -19,6 +19,7 @@ import {
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
* Serve-sim boots while the iOS download runs. After device preparation verifies
* any egress guard, its control API installs and launches the app.
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
@@ -117,9 +114,21 @@ export async function runAgentDeviceRemoteSessionAsync(
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
device:
| {
booted: Promise<unknown>;
ready: Promise<unknown>;
}
| {
iosSimulatorUdid: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
};
}
): Promise<void> {
signal?.throwIfAborted();
tasks.signal.throwIfAborted();
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -130,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync(
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
const externallyBootedDevice = 'booted' in device ? device : undefined;
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
@@ -158,12 +169,14 @@ export async function runAgentDeviceRemoteSessionAsync(
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
@@ -175,14 +188,20 @@ export async function runAgentDeviceRemoteSessionAsync(
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
@@ -190,18 +209,20 @@ export async function runAgentDeviceRemoteSessionAsync(
);
return webPreview;
});
const deviceReady = externallyBootedDevice?.ready;
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
throw tasks.signal.reason;
}
);
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -218,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
@@ -253,7 +275,7 @@ export async function runAgentDeviceRemoteSessionAsync(
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
@@ -297,6 +319,7 @@ export async function startAgentDeviceDaemonAsync({
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
signal?.throwIfAborted();
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
@@ -306,6 +329,7 @@ export async function startAgentDeviceDaemonAsync({
);
try {
signal?.throwIfAborted();
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
@@ -15,7 +15,6 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
@@ -33,6 +32,10 @@ import {
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import {
type ServeSimApplicationOptions,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
@@ -46,26 +49,15 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
* Starts a device session, downloading the app alongside daemon startup.
* On iOS, serve-sim boots the selected Simulator, then installs and launches
* the downloaded app. Build-tools verifies any egress guard before install and launch.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
@@ -170,6 +162,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
if (isIos) {
validateServeSimLaunchOptions({ launchArgs, openUrl });
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -179,109 +174,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
tasks.signal.throwIfAborted();
const { iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier,
env,
logger,
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
const booted = iosSimulatorUdid
? undefined
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger: taskLogger,
});
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// 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,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: { booted, ready },
});
downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
const download = downloaded;
applicationReady = download
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
if (booted) {
await tasks.untilAborted(booted);
}
const artifactPath = await download;
tasks.signal.throwIfAborted();
if (isIos) {
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: undefined;
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// 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,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: iosSimulatorUdid
? {
...iosStartup,
iosSimulatorUdid,
application: applicationReady,
}
: {
booted: booted!,
ready: applicationReady ?? tasks.untilAborted(booted!),
},
});
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
}
}),
});
}
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApiError,
} from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
describe(startAgentDeviceEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('uploads normalized events to the session event artifact once', async () => {
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
});
it('does not fail the session when event log setup fails', async () => {
const error = new Error('WWW unavailable');
const ctx = createContext({ realtimeLogs: true });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
toPromise: async () => ({ error }),
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
jest.mocked(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
);
const logger = createLogger();
const collection = await startAgentDeviceEventCollectionAsync({
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
},
'Could not persist device run session events to the artifact.'
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not persist device run session events',
expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
{
level: 'warning',
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startArgentEventCollectionAsync } from '../argentEvents';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
describe(startArgentEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const TOKEN = 'device-run-session-token-value';
function jsonResponse(body: unknown): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
describe(uploadDeviceRunSessionArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(turtleFetch).mockReset();
});
it('streams an artifact through a signed upload URL', async () => {
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
},
}),
});
const ctx = {
graphqlClient: {
mutation,
},
} as unknown as CustomBuildContext;
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
openStream: () => stream,
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
'POST',
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
json: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
}),
{ fetch: expect.any(Function) }
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -77,26 +88,19 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
controller.abort();
return jsonResponse({
data: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
};
},
},
});
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(ctx, {
uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationFetch = mutation.mock.calls[0][2].fetch;
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
try {
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
})
);
} finally {
mockFetch.mockRestore();
}
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
expect(allocationSignal).toBeInstanceOf(AbortSignal);
expect(allocationSignal?.aborted).toBe(true);
});
});
@@ -1,7 +1,7 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { getEventListeners } from 'node:events';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
@@ -10,20 +10,43 @@ import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import * as processes from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const PREVIEW_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
function uploadSessionResponse(): InstanceType<typeof Response> {
return new Response(JSON.stringify({ data: { uploadSession } }), {
headers: { 'content-type': 'application/json' },
});
}
describe(startDeviceRunSessionPreview, () => {
const image = Buffer.from('webp-image');
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
let mutation: jest.Mock;
let logger: bunyan;
let ctx: CustomBuildContext;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
@@ -31,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
beforeEach(() => {
jest.useFakeTimers();
captureAsync = jest.fn().mockResolvedValue(image);
mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
},
},
},
},
}),
});
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
ctx = createCtx();
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -82,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
@@ -92,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -102,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
@@ -126,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
@@ -156,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
});
});
describe('session preview with the real GraphQL client', () => {
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
let graphqlFetch: jest.SpyInstance;
describe('session preview upload session from the worker API', () => {
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
let logger: bunyan;
beforeEach(() => {
jest.useFakeTimers();
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -193,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
jest.useRealTimers();
});
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
const start = (ctx: CustomBuildContext = createCtx()) => {
preview = startDeviceRunSessionPreview({
ctx: { graphqlClient } as CustomBuildContext,
ctx,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
@@ -208,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
return preview;
};
it('preserves the worker authentication when creating the upload session', async () => {
it('sends the device run session token when creating the upload session', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
const [, options] = graphqlFetch.mock.calls[0];
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
expect(turtleFetch).toHaveBeenCalledWith(
PREVIEW_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
})
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
start({
env: {},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext);
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(SystemError) },
'Could not refresh the session preview; retrying in 60 seconds.'
);
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(err.message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it.each(['shutdown', 'timeout'])('aborts a stalled worker API request on %s', async reason => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
let requestSignal: AbortSignal | undefined;
let rejectRequest!: (error: Error) => void;
graphqlFetch.mockImplementation(
(_url, options) =>
jest.mocked(turtleFetch).mockImplementation(
(_url, _method, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
requestSignal = options.signal as AbortSignal;
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
@@ -255,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -1,6 +1,5 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
@@ -25,28 +24,23 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creationBodies: string[];
let creationTokens: (string | undefined)[];
beforeEach(async () => {
creationBodies = [];
creationTokens = [];
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => {
creationBodies.push(body);
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
);
});
} else {
@@ -60,12 +54,9 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -181,8 +172,9 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.map(body => JSON.parse(body))
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
@@ -1,5 +1,5 @@
import type { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import { BuildRuntimePlatform } from '@expo/steps';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
@@ -10,6 +10,7 @@ import {
screenshotArtifactDetails,
} from '../deviceRunSessionScreenshotNames';
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.mock('../../../sentry');
function deferred<T>() {
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
@@ -40,42 +41,32 @@ beforeEach(async () => {
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
request.setEncoding('utf8');
let body = '';
if (request.method === 'GET') {
const requestUrl = new URL(request.url, 'http://localhost');
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
}
request.on('data', chunk => (body += chunk));
request.on('end', () => {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
if (request.url === '/v2/device-run-session-worker/application') {
sessionLookups++;
response.setHeader('Content-Type', 'application/json');
if (sessionLookupFails) {
response.statusCode = 503;
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
} else {
response.end(
JSON.stringify({
data: {
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
applicationArchiveUrl: null,
},
},
})
@@ -92,13 +83,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
})
);
});
@@ -113,12 +98,10 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
runtimePlatform: BuildRuntimePlatform.DARWIN,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -127,7 +110,7 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(JSON.parse(creationBodies[0]).variables).toEqual(
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: expect.objectContaining({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
appSlug: 'my-app',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
}),
})
);
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
// The worker API does not return the app slug, so the filename starts with the platform.
expect(JSON.parse(creationBodies[0])).toEqual({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
});
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
stallCreation = true;
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
@@ -1,6 +1,5 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { createReadStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
creates++;
authorization = request.headers.authorization;
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
@@ -63,13 +63,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
})
);
});
@@ -84,12 +78,9 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
attempts: 3,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
@@ -281,7 +272,7 @@ it.each([200, 400, 503])(
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
@@ -298,7 +289,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(openStream).not.toHaveBeenCalled();
@@ -7,6 +7,7 @@ import {
DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
requireDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
@@ -88,6 +89,36 @@ describe(getDeviceRunSessionWorkerApi, () => {
});
});
describe(requireDeviceRunSessionWorkerApi, () => {
it('returns the client when the job has a device run session token', () => {
expect(
requireDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeInstanceOf(DeviceRunSessionWorkerApi);
});
it('throws a system error when the job has no device run session token', () => {
const error = (() => {
try {
requireDeviceRunSessionWorkerApi({
env: { __API_SERVER_URL: 'https://api.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext);
} catch (err) {
return err;
}
return undefined;
})();
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
});
});
describe(DeviceRunSessionWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
@@ -101,7 +132,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'session',
json: undefined,
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'startAsync',
@@ -111,7 +142,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'start',
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'stopAsync',
@@ -120,7 +151,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'stop',
json: undefined,
data: { id: 'drs-id', status: 'stopped' },
expected: { id: 'drs-id', status: 'STOPPED' },
expected: { id: 'drs-id', status: 'stopped' },
},
{
name: 'createArtifactUploadSessionAsync',
@@ -217,20 +248,16 @@ describe(DeviceRunSessionWorkerApi, () => {
}
);
it.each([
['new', 'NEW'],
['queued', 'QUEUED'],
['starting', 'STARTING'],
['in-progress', 'IN_PROGRESS'],
['stopped', 'STOPPED'],
['errored', 'ERRORED'],
])('maps the www status %s to the GraphQL status %s', async (wwwStatus, status) => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: wwwStatus } }));
it.each(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])(
'returns the www status %s unchanged',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status } }));
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
});
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
}
);
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { setImmediate } from 'node:timers/promises';
@@ -105,8 +105,6 @@ async function waitForCallAsync(mock: jest.Mock): Promise<void> {
}
}
const env = { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
const eventSource = {
producer: 'test-producer',
findEventFilesAsync: async () => [],
@@ -203,7 +201,7 @@ describe('with a device run session token', () => {
})
);
const args = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
expect(args).toEqual([
'--stun-url',
@@ -426,29 +424,38 @@ describe('with a device run session token', () => {
});
describe('without a device run session token', () => {
it('polls the session status with GraphQL', async () => {
const MISSING_TOKEN_MESSAGE =
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./;
function expectMissingTokenError(error: unknown): void {
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(MISSING_TOKEN_MESSAGE);
}
function expectMissingTokenWarning(message: string): void {
const warning = (jest.mocked(logger.warn).mock.calls as unknown[][]).find(
([, warningMessage]) => warningMessage === message
);
expect(warning).toBeDefined();
expectMissingTokenError((warning![0] as { err: unknown }).err);
}
it('fails the status poll with a system error', async () => {
const ctx = createCtx({ withToken: false });
jest
.mocked(ctx.graphqlClient.query)
.mockReturnValueOnce(
graphqlResult({ deviceRunSessions: { byId: { id: 'drs-id', status: 'STOPPED' } } }) as never
);
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
expect(ctx.graphqlClient.query).toHaveBeenCalledWith(expect.anything(), {
const error = await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
});
logger,
}).catch(err => err);
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('stops an idle session with GraphQL', async () => {
it('logs the failed idle stop and ends the wait', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' } },
}) as never
);
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -457,65 +464,43 @@ describe('without a device run session token', () => {
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expectMissingTokenWarning(
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('starts the session with GraphQL', async () => {
it('fails the start with a system error', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { startDeviceRunSession: { id: 'drs-id', status: 'IN_PROGRESS' } },
}) as never
);
await uploadRemoteSessionConfigAsync({
const error = await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
});
}).catch(err => err);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
});
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('falls back to no TURN ICE servers', async () => {
const ctx = createCtx({ withToken: false });
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
expectMissingTokenWarning(
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('fetches TURN ICE servers from the device run sessions route with the robot token', async () => {
it('fails the artifact upload with a system error before any PUT', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` },
timeout: 5000,
retries: 1,
})
);
});
it('creates artifact upload sessions with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
@@ -523,23 +508,16 @@ describe('without a device run session token', () => {
kind: undefined,
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
});
}).catch(err => err);
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the event log upload session with GraphQL', async () => {
it('runs the session without persisting events', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/events', headers: {} },
},
},
}) as never
);
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
@@ -549,24 +527,14 @@ describe('without a device run session token', () => {
});
await collection.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expectMissingTokenWarning('Could not persist device run session events to the artifact.');
expect(jest.mocked(RemoteLoggerStream)).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the preview upload session with GraphQL', async () => {
it('logs the failed preview upload session and uploads no preview', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
const preview = startDeviceRunSessionPreview({
ctx,
@@ -574,29 +542,25 @@ describe('without a device run session token', () => {
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(fetch));
await waitForCallAsync(jest.mocked(logger.warn));
await preview.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expectMissingTokenWarning('Could not refresh the session preview; retrying in 60 seconds.');
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('loads the screenshot naming details with GraphQL', async () => {
it('names screenshots with capture timestamps', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
graphqlResult({
deviceRunSessions: {
byId: { platform: 'ANDROID', app: { slug: 'app' }, build: null },
},
}) as never
);
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
platform: 'ANDROID',
app: { slug: 'app' },
build: null,
});
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toBeNull();
expectMissingTokenWarning(
'Could not load screenshot session details; using capture timestamps.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('looks up the build archive URL with GraphQL', async () => {
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -26,6 +26,7 @@ import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
@@ -154,73 +155,70 @@ describe(spawnDetached, () => {
);
});
function createCtxMock(): CustomBuildContext {
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_URL = 'https://api.expo.test/v2/device-run-session-worker/';
function createCtxMock({ withToken = true }: { withToken?: boolean } = {}): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: { robotAccessToken: 'robot-token' },
},
} as unknown as CustomBuildContext;
}
/** A successful device run session worker API response with the `{ data }` body. */
function workerApiResponse(data: unknown): Awaited<ReturnType<typeof turtleFetch>> {
return {
ok: true,
status: 200,
json: async () => ({ data }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
/** The number of device run session worker API requests to the route. */
function workerApiCallCount(route: string): number {
return jest
.mocked(turtleFetch)
.mock.calls.filter(([url]) => url === `${DEVICE_RUN_SESSION_WORKER_URL}${route}`).length;
}
/**
* Serves session status polls from `results` in order, and idle stops, through the device run
* session worker API.
*/
function createStatusCtxMock(
results: (
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
| { status: 'new' | 'in-progress' | 'stopped' | 'errored' }
| { error: Error }
| { data: unknown }
)[],
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
{ stopError }: { stopError?: Error } = {}
): CustomBuildContext {
const query = jest.fn(() => {
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url === `${DEVICE_RUN_SESSION_WORKER_URL}stop`) {
if (stopError) {
throw stopError;
}
return workerApiResponse({ id: 'drs-id', status: 'stopped' });
}
const result = results.shift();
if (!result) {
throw new Error('No mocked status result available');
}
return {
toPromise: async () => {
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return { data: result.data };
}
return {
data: {
deviceRunSessions: {
byId: {
id: 'drs-id',
status: result.status,
},
},
},
};
},
};
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return workerApiResponse(result.data);
}
return workerApiResponse({ id: 'drs-id', status: result.status });
});
const mutation = jest.fn(() => ({
toPromise: async () => {
if (ensureStoppedError) {
return { error: ensureStoppedError };
}
return {
data: {
deviceRunSession: {
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
},
},
};
},
}));
return {
graphqlClient: {
query,
mutation,
},
} as unknown as CustomBuildContext;
return createCtxMock();
}
function createEnvMock(): BuildStepEnv {
@@ -1098,6 +1096,8 @@ describe(startDeviceSessionHostAsync, () => {
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
iceServers: [
@@ -1595,24 +1595,21 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
it('requests TURN ICE servers from the device run session worker API and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue(
workerApiResponse({
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
})
);
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -1627,10 +1624,10 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
`${DEVICE_RUN_SESSION_WORKER_URL}turn-ice-servers`,
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer robot-token' },
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
});
@@ -1639,15 +1636,33 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { logger });
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
it('returns [] and warns when the job has no device run session token', async () => {
const logger = createLoggerMock();
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock({ withToken: false }), {
logger,
});
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: expect.stringMatching(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
),
}),
},
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
});
describe(waitForDeviceRunSessionStoppedAsync, () => {
@@ -1663,7 +1678,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
it('continues polling until the device run session is stopped', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }, { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1672,13 +1687,59 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('polls the session status through the worker API with the abort signal', async () => {
const ctx = createStatusCtxMock([{ status: 'stopped' }]);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
`${DEVICE_RUN_SESSION_WORKER_URL}session`,
'GET',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
timeout: 30_000,
})
);
});
it('keeps polling while the session status is new', async () => {
const ctx = createStatusCtxMock([{ status: 'new' }, { status: 'stopped' }]);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
});
expect(workerApiCallCount('session')).toBe(2);
});
it('fails with a system error when the job has no device run session token', async () => {
const error = await waitForDeviceRunSessionStoppedAsync({
ctx: createCtxMock({ withToken: false }),
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('returns normally when the maximum duration elapses', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const logger = createLoggerMock();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1692,7 +1753,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
durationTimeoutCallback();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id reached its maximum duration.'
);
@@ -1701,7 +1762,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
it('clears the duration timeout when the session stops first', async () => {
await waitForDeviceRunSessionStoppedAsync({
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
ctx: createStatusCtxMock([{ status: 'stopped' }]),
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
@@ -1723,12 +1784,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
signal: abortController.signal,
});
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
expect(workerApiCallCount('session')).toBe(0);
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('throws when the device run session errors', async () => {
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
const ctx = createStatusCtxMock([{ status: 'errored' }]);
await expect(
waitForDeviceRunSessionStoppedAsync({
@@ -1742,7 +1803,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
it('clears the duration timeout when the build step is aborted', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const abortController = new AbortController();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1755,12 +1816,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
abortController.abort();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(1);
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('logs and retries transient polling errors', async () => {
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1769,7 +1830,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1781,11 +1842,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
);
});
it('logs and retries when the status response is missing', async () => {
const ctx = createStatusCtxMock([
{ data: { deviceRunSessions: { byId: null } } },
{ status: 'STOPPED' },
]);
it('logs and retries when the status response does not match the route contract', async () => {
const ctx = createStatusCtxMock([{ data: { id: 'drs-id' } }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1794,7 +1852,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1802,7 +1860,9 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not poll device run session status',
expect.objectContaining({
message: 'Device run session drs-id status response was missing.',
message: expect.stringContaining(
'GET /v2/device-run-session-worker/session returned an unexpected response'
),
}),
{ level: 'warning' }
);
@@ -1823,8 +1883,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
jest.useRealTimers();
});
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
function manyInProgressStatuses(): { status: 'in-progress' }[] {
return Array.from({ length: 20 }, () => ({ status: 'in-progress' as const }));
}
it('stops the session when no activity is observed within the max idle time', async () => {
@@ -1842,15 +1902,15 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
// One minute at the 5-second poll interval is 12 status polls.
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(12);
expect(workerApiCallCount('stop')).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
);
});
it('keeps the session alive while events keep arriving', async () => {
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1865,14 +1925,14 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
expect(workerApiCallCount('session')).toBe(21);
expect(workerApiCallCount('stop')).toBe(0);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});
it('still returns when the session cannot be marked stopped', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
ensureStoppedError: new Error('forbidden'),
stopError: new Error('forbidden'),
});
const logger = createLoggerMock();
@@ -1886,7 +1946,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('stop')).toBe(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
@@ -6,11 +6,13 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
// Only WWW's allocation response is substituted; it does not verify cloud storage.
// WWW's device run session worker API is served by the local server; it does not verify cloud
// storage.
jest.mock('../../../sentry');
jest.unmock('fs');
jest.unmock('node:fs');
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
let streamRequest: { url?: string; authorization?: string } | undefined;
let uploaded: Buffer | undefined;
let uploadedLength: string | undefined;
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
server.on('request', (request, response) => {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
uploadSessionRequest = {
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
};
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify({
data: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
})
);
});
return;
}
if (request.method === 'GET') {
streamRequest = { url: request.url, authorization: request.headers.authorization };
response.writeHead(200, {
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
});
expect(await readFile(filePath)).toEqual(expected);
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
},
},
}),
});
await uploadServeSimLogsFileAsync(
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
expoApiV2BaseUrl: `${baseUrl}/v2/`,
} as unknown as CustomBuildContext,
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
);
expect(mutation.mock.calls[0][1]).toEqual({
deviceRunSessionId: 'session-test',
input: {
expect(uploadSessionRequest).toEqual({
token: 'device-run-session-token',
body: {
name: 'App logs (device-B)',
filename: 'app-logs.ndjson',
kind: 'simulator-log',
@@ -1,8 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
ArtifactPutNetworkError,
@@ -11,23 +9,7 @@ import {
putArtifactWithRetriesAsync,
} from './artifactPut';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
$deviceRunSessionId: ID!
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
) {
deviceRunSession {
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
uploadSession {
url
headers
}
}
}
}
`);
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
deviceRunSessionId: string;
@@ -72,54 +54,28 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
): Promise<ArtifactUploadSession> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
signal.throwIfAborted();
return uploadSession;
}
const result = await ctx.graphqlClient
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
signal.throwIfAborted();
return uploadSession;
}
@@ -1,6 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import fs from 'node:fs';
import { StringDecoder } from 'node:string_decoder';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
@@ -10,26 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
import { Sentry } from '../../sentry';
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const POLL_INTERVAL_MS = 1_000;
const UPLOAD_INTERVAL_MS = 5_000;
const EAS_LOGS_THREAD = 'session-events';
const EAS_LOGS_BUFFER_RETENTION_MS = 30_000;
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createEventLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* The normalized, producer-agnostic event shape uploaded to the API server.
* Every remote-session producer (agent-device, argent, ...) tails its own
@@ -350,35 +336,17 @@ async function createEventLogUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string
): Promise<SignedUrl> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
})
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${result.error.message}`,
{ cause: result.error }
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
return {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
};
}
async function collectEventFileAsync({
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
@@ -12,25 +11,12 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { killProcessGroup } from '../../utils/processes';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
@@ -122,35 +108,7 @@ async function createPreviewUploadSessionAsync(
deviceRunSessionId: string,
signal: AbortSignal
): Promise<{ url: string; headers: Record<string, string> }> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
return await workerApi.createPreviewUploadSessionAsync({ signal });
}
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
return {
url: session.url,
headers: session.headers as Record<string, string>,
};
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
}
/**
@@ -1,11 +1,8 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const SHORT_COMMIT_HASH_LENGTH = 7;
const MAX_APP_SLUG_LENGTH = 80;
@@ -17,38 +14,9 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
const SCREENSHOT_SESSION_QUERY = graphql(`
query ScreenshotSession($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
platform
app { slug }
build {
id
gitCommitHash
app { slug }
}
}
}
}
`);
const AppSchema = z.object({ slug: z.string() });
const ScreenshotSessionSchema = z.object({
platform: z.enum(['IOS', 'ANDROID']),
app: AppSchema,
build: z
.object({
id: z.string(),
gitCommitHash: z.string().nullable(),
app: AppSchema,
})
.nullable(),
});
/**
* Session details used to name screenshots. `app` is null when the source does not report an app
* slug: the device run session worker API does not return one.
* Session details used to name screenshots. `app` is always null: the device run session worker
* API does not return the app slug.
*/
export type ScreenshotSession = {
platform: 'IOS' | 'ANDROID';
@@ -63,30 +31,22 @@ export async function loadScreenshotSessionAsync(
): Promise<ScreenshotSession | null> {
try {
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
const application = await workerApi.getApplicationAsync({ signal });
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
}
const result = await ctx.graphqlClient
.query(SCREENSHOT_SESSION_QUERY, { deviceRunSessionId }, graphqlAbortContext(signal))
.toPromise();
if (result.error) {
throw result.error;
}
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
signal,
});
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
} catch (err) {
logger.warn(
{ err, deviceRunSessionId },
@@ -20,24 +20,10 @@ export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
/**
* www returns the entity value of the status. The client maps it to the GraphQL enum value, so
* callers compare statuses the same way on both paths.
*/
const DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE = {
new: 'NEW',
queued: 'QUEUED',
starting: 'STARTING',
'in-progress': 'IN_PROGRESS',
stopped: 'STOPPED',
errored: 'ERRORED',
} as const;
const DeviceRunSessionStatusSchema = z.object({
id: z.string(),
status: z
.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])
.transform(status => DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE[status]),
// The status entity values that www returns.
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
});
const UploadSessionSchema = z.object({
@@ -307,6 +293,24 @@ export function getDeviceRunSessionWorkerApi(
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
}
/**
* Returns the client for the device run session worker API, which every device run session job
* uses to call www about its session.
*/
export function requireDeviceRunSessionWorkerApi(
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
): DeviceRunSessionWorkerApi {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (!workerApi) {
throw new SystemError(
`This device run session job has no ${DEVICE_RUN_SESSION_TOKEN_ENV} secret. ` +
'www gives one to every device run session job, and the worker needs it to call the ' +
'device run session API. Start a new session, and contact support if it happens again.'
);
}
return workerApi;
}
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
if (ctx.expoApiV2BaseUrl) {
return ctx.expoApiV2BaseUrl;
@@ -492,7 +492,7 @@ async function startDeviceSessionHostInternalAsync(
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
@@ -10,8 +10,6 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
@@ -29,49 +27,15 @@ import {
import { Sentry } from '../../sentry';
import { isProcessGroupRunning } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import {
type DeviceRunSessionStatus,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
deviceRunSession {
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
// A request to the device run session worker API that hangs counts as a failed poll or stop.
const DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS = 30_000;
@@ -230,15 +194,12 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
try {
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
pollErrorCount = 0;
if (status === 'STOPPED') {
if (status === 'stopped') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
if (status === 'errored') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
@@ -269,24 +230,14 @@ async function fetchDeviceRunSessionStatusAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<string | undefined> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
// One request per poll; the polling loop retries on its own schedule.
return (
await workerApi.getSessionAsync({
signal,
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
})
).status;
}
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
return result.data?.deviceRunSessions?.byId?.status;
): Promise<DeviceRunSessionStatus['status']> {
// One request per poll; the polling loop retries on its own schedule.
return (
await requireDeviceRunSessionWorkerApi(ctx).getSessionAsync({
signal,
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
})
).status;
}
// Best effort: when this fails, the caller still tears the session down and the
@@ -301,17 +252,9 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
logger: bunyan;
}): Promise<void> {
try {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
await workerApi.stopAsync({ timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS });
} else {
const result = await ctx.graphqlClient
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
}
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not mark idle device run session as stopped', error, {
@@ -476,12 +419,6 @@ export async function ensureFfmpegInstalledOnceAsync({
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
@@ -523,10 +460,10 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
*/
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
{ logger }: { logger: bunyan }
): Promise<string[]> {
try {
const iceServers = await fetchTurnIceServersAsync(ctx, { env, logger });
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
const args = turnIceServersToWebPreviewArgs(iceServers);
if (args.length > 0) {
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
@@ -545,38 +482,13 @@ export async function fetchWebPreviewTurnArgsAsync(
async function fetchTurnIceServersAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
{ logger }: { logger: bunyan }
): Promise<TurnIceServers> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
return await workerApi.fetchTurnIceServersAsync({ timeoutMs: 5000, retries: 1, logger });
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
return data.iceServers;
return await requireDeviceRunSessionWorkerApi(ctx).fetchTurnIceServersAsync({
timeoutMs: 5000,
retries: 1,
logger,
});
}
export async function uploadRemoteSessionConfigAsync({
@@ -593,26 +505,15 @@ export async function uploadRemoteSessionConfigAsync({
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
return;
}
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
@@ -1,5 +1,9 @@
# create-eas-build-function
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.1.0",
"version": "25.1.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.1.0"
"@expo/steps": "25.1.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4
View File
@@ -1,5 +1,9 @@
# @expo/downloader
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.1.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,9 @@
# eas-build-cache-provider
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.1.0",
"version": "25.1.1",
"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",
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-build-job
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.1.0",
"version": "25.1.1",
"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": "25.1.0",
"@expo/logger": "25.1.1",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.1.0",
"@expo/turtle-spawn": "25.1.1",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+4
View File
@@ -1,5 +1,9 @@
# eas-cli
## 25.1.1
No changes in this release.
## 25.1.0
### Minor Changes
+142 -142
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.1.0",
"version": "25.1.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.1.0",
"@expo/eas-json": "25.1.0",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-json": "25.1.1",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.1.0",
"@expo/logger": "25.1.1",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.0",
"@expo/steps": "25.1.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,25 @@
import EasCommand from '../../commandUtils/EasCommand';
import { mockCommandContext } from './utils';
jest.mock('fs');
class TestCommand extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Experimentation,
};
async runAsync(): Promise<void> {}
}
describe(mockCommandContext, () => {
it('provides a disabled experimentation client that returns defaults', async () => {
const ctx = mockCommandContext(TestCommand, {});
const client = await ctx.experimentation.getClientAsync();
expect(client.getUserNamespace('any').getParam('variant', 'control')).toBe('control');
expect(client.getAccountNamespace('any', { accountId: 'a' }).getParam('enabled', true)).toBe(
true
);
expect(client.getDeviceNamespace('any').getParam('count', 3)).toBe(3);
});
});
@@ -7,6 +7,9 @@ import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import {
AppFragment,
Role,
@@ -96,6 +99,11 @@ export function mockCommandContext<
if (contextKey === 'sessionManager') {
result.sessionManager = {};
}
if (contextKey === 'experimentation') {
result.experimentation = LazyExperimentationClient.fromClient(
ExperimentationClient.createDisabled(ExperimentOverrides.empty())
);
}
if (contextKey === 'projectConfig') {
result.projectConfig = {
exp: overrides.exp ?? getMockExpoConfig(),
@@ -21,12 +21,17 @@ export type AnalyticsEvent =
| SubmissionEvent
| MetadataEvent
| BillingEvent
| SimulatorEvent;
| SimulatorEvent
| ExperimentationEvent;
export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
export enum ExperimentationEvent {
EXPERIMENT_VIEWED = 'Experiment Viewed',
}
export enum BillingEvent {
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
}
@@ -105,6 +110,8 @@ export interface Analytics {
export interface AnalyticsWithOrchestration extends Analytics {
setActor(actor: Actor): void;
flushAsync(): Promise<void>;
/** The persisted RudderStack anonymous ID, or null when analytics is disabled. */
getDeviceId(): string | null;
}
const USER_SETTINGS_KEY_AMPLITUDE_ENABLED = 'amplitudeEnabled';
@@ -183,6 +190,9 @@ class NoOpAnalytics implements AnalyticsWithOrchestration {
logEvent(): void {}
setActor(): void {}
async flushAsync(): Promise<void> {}
getDeviceId(): null {
return null;
}
}
const RudderstackAnalyticsConfig =
@@ -234,6 +244,10 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
this.identifiedActor = actor;
}
public getDeviceId(): string {
return this.persistentDeviceId;
}
public logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void {
const userId = this.identifiedActor?.id;
const deviceId = this.persistentDeviceId;
@@ -140,3 +140,55 @@ describe(isAnalyticsOptedOutAsync, () => {
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(true);
});
});
describe('getDeviceId', () => {
// Backs UserSettings with an in-memory store so a value written by createAnalyticsAsync is read back.
function useInMemoryUserSettings(initial: Record<string, unknown>): void {
const stored: Record<string, unknown> = { ...initial };
(userSettingsMock.setAsync as jest.Mock).mockImplementation(
async (key: string, value: unknown) => {
stored[key] = value;
return {};
}
);
(userSettingsMock.getAsync as jest.Mock).mockImplementation(
async (key: string, defaultValue: unknown) => (key in stored ? stored[key] : defaultValue)
);
}
it('returns the persisted analytics device ID', async () => {
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBe('persistent-device-id');
});
it('creates and persists a device ID when none is stored', async () => {
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
key === 'analyticsDeviceId' ? null : defaultValue
);
const analytics = await createAnalyticsAsync();
const deviceId = analytics.getDeviceId();
expect(deviceId).toMatch(/^[0-9a-f-]{36}$/);
expect(userSettingsMock.setAsync).toHaveBeenCalledWith('analyticsDeviceId', deviceId);
});
it('is null when DISABLE_EAS_ANALYTICS is set', async () => {
useInMemoryUserSettings({ analyticsDeviceId: 'persistent-device-id' });
process.env.DISABLE_EAS_ANALYTICS = '1';
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});
it('is null when running behind https_proxy', async () => {
process.env.https_proxy = 'http://proxy.local:8080';
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});
it('is null when analytics were turned off with eas analytics off', async () => {
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
key === 'analyticsEnabled' ? false : defaultValue
);
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});
});
@@ -0,0 +1,280 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { Platform } from '@expo/eas-build-job';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
EMPTY_EXPERIMENTATION_DEFINITION,
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
import {
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
getFreeTierLimitAgentGuidanceExperiment,
maybeAppendFreeTierLimitAgentGuidanceAsync,
resolveFreeTierLimitAgentGuidanceVariantAsync,
} from '../freeTierLimitGuidance';
jest.mock('../../analytics/agent');
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-agent-guidance';
/** Every account is in the experiment: 50% control, 50% treatment. */
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
experiments: [
{
experimentName: EXPERIMENT_NAME,
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
},
exposureLoggerType: 'rudderstack',
},
],
namespaces: [
{
name: FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{
method: 'ADD',
name: `${EXPERIMENT_NAME} full`,
experimentName: EXPERIMENT_NAME,
numSegments: 1000,
},
],
},
],
};
function createClient(): {
client: ExperimentationClient;
analytics: ReturnType<typeof createMockAnalytics>;
} {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: ACCOUNT_DEFINITION,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
return { client, analytics };
}
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
function findAccountIdsByVariant(): Map<string, string> {
const { client } = createClient();
return findUnitsByValue(
accountId =>
client.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, {
accountId,
}),
'variant',
'control',
['control', 'treatment']
);
}
describe(getFreeTierLimitAgentGuidanceExperiment, () => {
it('returns the handle when the command has an experimentation client', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierLimitAgentGuidanceExperiment({ experimentation, accountId: 'account-id' })
).toEqual({ experimentation, accountId: 'account-id' });
});
it('returns undefined when the command has no experimentation client', () => {
expect(getFreeTierLimitAgentGuidanceExperiment({ accountId: 'account-id' })).toBeUndefined();
});
});
describe(resolveFreeTierLimitAgentGuidanceVariantAsync, () => {
const accountIdsByVariant = findAccountIdsByVariant();
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
const controlAccountId = accountIdsByVariant.get('control')!;
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('does not load the client when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const loadAsync = jest.fn(async () => createClient().client);
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: new LazyExperimentationClient(loadAsync),
accountId: treatmentAccountId,
})
).toBe('control');
expect(loadAsync).not.toHaveBeenCalled();
});
it('loads the client once across repeated reads', async () => {
const { client, analytics } = createClient();
const loadAsync = jest.fn(async () => client);
const experiment = {
experimentation: new LazyExperimentationClient(loadAsync),
accountId: treatmentAccountId,
};
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
expect(loadAsync).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
});
it('returns control and logs no exposure when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('returns the account variant and logs the exposure once when a coding agent runs the CLI', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('treatment');
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('treatment');
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('logs the exposure for control accounts too', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: controlAccountId,
})
).toBe('control');
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'control',
unit: controlAccountId,
});
});
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: EMPTY_EXPERIMENTATION_DEFINITION,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe(maybeAppendFreeTierLimitAgentGuidanceAsync, () => {
const treatmentAccountId = findAccountIdsByVariant().get('treatment')!;
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('returns the message unchanged and logs no exposure when the command has no experimentation client', async () => {
expect(await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, undefined)).toBe(
'Error 1'
);
});
it('logs the exposure and appends the guidance in the treatment arm', async () => {
const { client, analytics } = createClient();
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.IOS, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe(
[
'Error 1',
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('returns the message unchanged in the control arm', async () => {
const client = ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', 'control']])],
])
)
);
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('Error 1');
});
it('returns the message unchanged and logs no exposure when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const { client, analytics } = createClient();
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('Error 1');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
@@ -3,11 +3,15 @@ import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { getError } from '../../__tests__/commands/utils';
import { getErrorAsync } from '../../__tests__/commands/utils';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { EasCommandError } from '../../commandUtils/errors';
import Build from '../../commands/build';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import Log, { link } from '../../log';
import { handleBuildRequestError } from '../build';
import { handleBuildRequestErrorAsync } from '../build';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
@@ -20,11 +24,34 @@ import {
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
import {
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
FreeTierLimitAgentGuidanceExperiment,
} from '../freeTierLimitGuidance';
jest.mock('../../analytics/agent');
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
});
/** An experiment handle whose client is forced to the given variant through an override. */
function createExperiment(variant: string): FreeTierLimitAgentGuidanceExperiment {
return {
accountId: 'account-id',
experimentation: LazyExperimentationClient.fromClient(
ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
])
)
)
),
};
}
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
{
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
@@ -84,7 +111,7 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
@@ -97,7 +124,7 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
@@ -111,7 +138,7 @@ describe(Build.name, () => {
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch {}
expect(logDebugSpy).not.toHaveBeenCalled();
@@ -123,7 +150,7 @@ describe(Build.name, () => {
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch {}
expect(logDebugSpy).not.toHaveBeenCalled();
@@ -139,9 +166,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<TurtleDeprecatedJobFormatError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -162,8 +189,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
handleBuildRequestError(error, platform);
await getErrorAsync<EasBuildResourceClassNotAvailableInFreeTierError>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(
@@ -180,9 +207,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<TurtleDeprecatedJobFormatError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -203,8 +230,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
handleBuildRequestError(error, platform);
await getErrorAsync<EasBuildResourceClassNotAvailableInFreeTierError>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(
@@ -224,9 +251,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -244,9 +271,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -266,7 +293,7 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError, 'Error 1');
}
@@ -279,9 +306,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledAndroidError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledAndroidError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -300,9 +327,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
() => {
handleBuildRequestError(error, platform, accountName);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName);
}
);
@@ -313,6 +340,115 @@ describe(Build.name, () => {
);
});
});
describe('for free tier limit errors in the agent guidance experiment', () => {
beforeEach(() => {
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('appends the human required block for Android in the treatment arm', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
});
it('appends the human required block for iOS in the treatment arm', async () => {
const platform = Platform.IOS;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError =
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
});
it('does not append the block in the control arm', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('control'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
it('does not append the block when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
it('does not append the block to other server-side errors', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, undefined, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledError,
'Error 1'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
@@ -322,8 +458,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildFreeTierIosLimitExceededError>(() => {
handleBuildRequestError(error, platform, accountName);
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
await handleBuildRequestErrorAsync(error, platform, accountName);
});
assertReThrownError(
@@ -342,8 +478,8 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<RequestValidationError>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(
@@ -360,8 +496,8 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<RequestValidationError>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(
@@ -383,9 +519,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildDownForMaintenanceError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -403,9 +539,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildDownForMaintenanceError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -426,9 +562,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildTooManyPendingBuildsError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -446,9 +582,9 @@ describe(Build.name, () => {
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildTooManyPendingBuildsError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
@@ -471,8 +607,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -494,8 +630,8 @@ describe(Build.name, () => {
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -509,8 +645,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -537,8 +673,8 @@ describe(Build.name, () => {
'Response status: 400 Bad Request\n' +
`Request ID: ${mockRequestId}`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -561,7 +697,7 @@ describe(Build.name, () => {
});
try {
handleBuildRequestError(error, platform);
await handleBuildRequestErrorAsync(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"graphQLErrors": []'));
@@ -597,8 +733,8 @@ describe(Build.name, () => {
'Response status: 400 Bad Request\n' +
`Request ID: ${mockRequestId}`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -625,8 +761,8 @@ describe(Build.name, () => {
'Error message: Error 1\n' +
'Response status: 400 Bad Request';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -638,8 +774,8 @@ describe(Build.name, () => {
const error = { graphQLErrors: [], message: 'Something went wrong' };
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Something went wrong`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -654,8 +790,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -677,8 +813,8 @@ describe(Build.name, () => {
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -692,8 +828,8 @@ describe(Build.name, () => {
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
@@ -708,8 +844,8 @@ describe(Build.name, () => {
const platform = Platform.ANDROID;
const error = new Error('Non-graphQL-related error');
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
@@ -720,8 +856,8 @@ describe(Build.name, () => {
const platform = Platform.IOS;
const error = new Error('Non-graphQL-related error');
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
await handleBuildRequestErrorAsync(error, platform);
});
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
+20 -5
View File
@@ -28,6 +28,11 @@ import {
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import {
FreeTierLimitAgentGuidanceExperiment,
getFreeTierLimitAgentGuidanceExperiment,
maybeAppendFreeTierLimitAgentGuidanceAsync,
} from './freeTierLimitGuidance';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
@@ -219,7 +224,12 @@ export async function prepareBuildRequestForPlatformAsync<
});
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform, ctx.accountName);
return await handleBuildRequestErrorAsync(
error,
job.platform,
ctx.accountName,
getFreeTierLimitAgentGuidanceExperiment(ctx)
);
}
} else {
throw new Error('Unknown localBuildMode.');
@@ -240,11 +250,12 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
VALIDATION_ERROR: RequestValidationError,
};
export function handleBuildRequestError(
export async function handleBuildRequestErrorAsync(
error: any,
platform: Platform,
accountName?: string
): never {
accountName?: string,
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment
): Promise<never> {
logBuildRequestErrorDebugInfo(error);
const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors)
@@ -261,7 +272,11 @@ export function handleBuildRequestError(
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
throw new ErrorClass(
isFreeTierLimitError
? `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`
? await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
)
: message
);
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
+2
View File
@@ -8,6 +8,7 @@ import { LocalBuildOptions } from './local';
import { Analytics, AnalyticsEventProperties } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
@@ -66,4 +67,5 @@ export interface BuildContext<T extends Platform> {
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
}
@@ -16,6 +16,7 @@ import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/An
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
@@ -46,6 +47,7 @@ export async function createBuildContextAsync<T extends Platform>({
isVerboseLoggingEnabled,
whatToTest,
env,
experimentation,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -70,6 +72,7 @@ export async function createBuildContextAsync<T extends Platform>({
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
env,
@@ -159,6 +162,7 @@ export async function createBuildContextAsync<T extends Platform>({
isVerboseLoggingEnabled,
env,
whatToTest,
experimentation,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
@@ -0,0 +1,60 @@
import { Platform } from '@expo/eas-build-job';
import { getAgentTelemetryContext } from '../analytics/agent';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import { requestedPlatformDisplayNames } from '../platform';
export const FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE =
'cli-build-free-tier-limit-agent-guidance';
export type FreeTierLimitAgentGuidanceVariant = 'control' | 'treatment';
export type FreeTierLimitAgentGuidanceExperiment = {
experimentation: LazyExperimentationClient;
accountId: string;
};
export function getFreeTierLimitAgentGuidanceExperiment(ctx: {
experimentation?: LazyExperimentationClient;
accountId: string;
}): FreeTierLimitAgentGuidanceExperiment | undefined {
return ctx.experimentation
? { experimentation: ctx.experimentation, accountId: ctx.accountId }
: undefined;
}
/**
* Resolves the account's variant. Only runs driven by a coding agent are eligible. Other runs get
* control before the client loads, so they make no config request and log no exposure.
*/
export async function resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation,
accountId,
}: FreeTierLimitAgentGuidanceExperiment): Promise<FreeTierLimitAgentGuidanceVariant> {
if (!getAgentTelemetryContext()) {
return 'control';
}
const client = await experimentation.getClientAsync();
return client
.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, { accountId })
.getParam<FreeTierLimitAgentGuidanceVariant>('variant', 'control');
}
export function formatFreeTierLimitAgentGuidance(platform: Platform): string {
const platformName = requestedPlatformDisplayNames[platform];
return `Fix: Human required: ${platformName} builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.`;
}
export async function maybeAppendFreeTierLimitAgentGuidanceAsync(
message: string,
platform: Platform,
experiment: FreeTierLimitAgentGuidanceExperiment | undefined
): Promise<string> {
if (
!experiment ||
(await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment)) !== 'treatment'
) {
return message;
}
return `${message}\n${formatFreeTierLimitAgentGuidance(platform)}`;
}
@@ -30,6 +30,7 @@ import { Analytics } from '../analytics/AnalyticsManager';
import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/queries';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import {
AppPlatform,
BuildFragment,
@@ -118,6 +119,7 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync,
downloadSimBuildAutoConfirm,
envOverride,
experimentation,
}: {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
@@ -128,6 +130,8 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
downloadSimBuildAutoConfirm?: boolean;
envOverride?: Env;
/** Enables the Free plan limit guidance experiment for builds driven by a coding agent. */
experimentation?: LazyExperimentationClient;
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
@@ -240,6 +244,7 @@ export async function runBuildAndSubmitAsync({
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
env,
easJsonAccessor,
experimentation,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -378,6 +383,7 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
env,
easJsonAccessor,
experimentation,
}: {
projectDir: string;
flags: BuildFlags;
@@ -392,6 +398,7 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
easJsonAccessor: EasJsonAccessor;
env: Env;
experimentation?: LazyExperimentationClient;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
@@ -417,6 +424,7 @@ async function prepareAndStartBuildAsync({
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
experimentation,
});
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
@@ -7,6 +7,7 @@ import nullthrows from 'nullthrows';
import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
import DynamicLoggedInContextField from './context/DynamicLoggedInContextField';
import ExperimentationContextField from './context/ExperimentationContextField';
import {
DynamicPrivateProjectConfigContextField,
DynamicPublicProjectConfigContextField,
@@ -148,6 +149,14 @@ export default abstract class EasCommand extends Command {
ProjectId: {
projectId: new ProjectIdContextField(),
},
/**
* Experimentation client for user, account, and device experiments.
* Declare a login field first so user experiments see the actor.
* Account experiments take the account ID from the command.
*/
Experimentation: {
experimentation: new ExperimentationContextField(),
},
};
/**
@@ -48,6 +48,7 @@ const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn((): void => {}),
setActor: jest.fn((): void => {}),
flushAsync: jest.fn(async (): Promise<void> => {}),
getDeviceId: jest.fn((): string | null => null),
};
beforeEach(() => {
@@ -0,0 +1,111 @@
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { ExperimentationConfigQuery } from '../../graphql/queries/ExperimentationConfigQuery';
import Log from '../../log';
/** Caps how long the config fetch and the actor lookup can delay a command. */
const REQUEST_TIMEOUT_MS = 3000;
const CI_ENVIRONMENT_FLAGS = ['EAS_BUILD', 'CI'];
function isRunningInCi(): boolean {
return CI_ENVIRONMENT_FLAGS.some(flag => !!process.env[flag]);
}
/**
* Never prompts, throws, writes files, or reads the app config. A failed config fetch disables
* all scopes. CI runs skip the fetch and get defaults.
* The config fetch runs on the first `getClientAsync` call, so commands that never read a variant
* make no request.
*/
export default class ExperimentationContextField extends ContextField<LazyExperimentationClient> {
// One handle per command run, so repeated getContextAsync calls cannot log duplicate exposures.
private readonly clientByAnalytics = new WeakMap<
AnalyticsWithOrchestration,
LazyExperimentationClient
>();
async getValueAsync(options: ContextOptions): Promise<LazyExperimentationClient> {
let client = this.clientByAnalytics.get(options.analytics);
if (!client) {
client = new LazyExperimentationClient(() => this.buildClientAsync(options));
this.clientByAnalytics.set(options.analytics, client);
}
return client;
}
private async buildClientAsync({
sessionManager,
analytics,
}: ContextOptions): Promise<ExperimentationClient> {
const overrides = ExperimentOverrides.fromEnv();
if (isRunningInCi()) {
Log.debug('[experimentation] Running in CI. Experiments return defaults.');
return ExperimentationClient.createDisabled(overrides);
}
const deviceId = analytics.getDeviceId();
if (deviceId === null) {
Log.debug('[experimentation] Analytics is disabled. Experiments return defaults.');
return ExperimentationClient.createDisabled(overrides);
}
try {
const graphqlClient = createGraphqlClient(
{
accessToken: sessionManager.getAccessToken(),
sessionSecret: sessionManager.getSessionSecret(),
},
{ requestTimeoutMs: REQUEST_TIMEOUT_MS }
);
// The session manager skips the request once an actor is cached. Without a cached actor
// (for example, a stale session) it makes its own request, so it needs the same cap.
const [actorResult, configsResult] = await Promise.allSettled([
sessionManager.getUserAsync({ requestTimeoutMs: REQUEST_TIMEOUT_MS }),
ExperimentationConfigQuery.getConfigsAsync(graphqlClient),
]);
if (configsResult.status === 'rejected') {
Log.debug(
'[experimentation] Failed to fetch the experiment config. Experiments return defaults.',
errorMessage(configsResult.reason)
);
return ExperimentationClient.createDisabled(overrides);
}
const configs = configsResult.value;
return new ExperimentationClient(
{
user: configs.userConfig,
account: configs.accountConfig,
device: configs.deviceConfig,
},
{
user: actorResult.status === 'fulfilled' ? (actorResult.value?.id ?? null) : null,
device: deviceId,
},
overrides,
createLoggerConfig(analytics)
);
} catch (error) {
Log.debug(
'[experimentation] Failed to initialize experiments. Experiments return defaults.',
errorMessage(error)
);
return ExperimentationClient.createDisabled(overrides);
}
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,289 @@
import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { AnalyticsWithOrchestration } from '../../../analytics/AnalyticsManager';
import { TEST_DEFINITION } from '../../../experimentation/__tests__/fixtures';
import { ExperimentationConfigQuery } from '../../../graphql/queries/ExperimentationConfigQuery';
import Log from '../../../log';
import SessionManager from '../../../user/SessionManager';
import { Actor } from '../../../user/User';
import { ContextOptions } from '../ContextField';
import { ExperimentationClient } from '../../../experimentation/ExperimentationClient';
import ExperimentationContextField from '../ExperimentationContextField';
import { createGraphqlClient } from '../contextUtils/createGraphqlClient';
jest.mock('../../../env', () => ({
__esModule: true,
default: { experimentOverride: undefined as string | undefined },
}));
jest.mock('../../../graphql/queries/ExperimentationConfigQuery');
jest.mock('../../../log');
jest.mock('../contextUtils/createGraphqlClient');
const mockEnv = jest.requireMock('../../../env').default as { experimentOverride?: string };
const getConfigsAsyncMock = jest.mocked(ExperimentationConfigQuery.getConfigsAsync);
const fakeGraphqlClient = { fake: true } as any;
const actor = { __typename: 'User', id: 'user-1' } as unknown as Actor;
const emptyDefinition = { experiments: [], namespaces: [] };
const allConfigs = {
userConfig: TEST_DEFINITION,
accountConfig: TEST_DEFINITION,
deviceConfig: TEST_DEFINITION,
};
function createAnalytics(deviceId: string | null = 'device-1'): AnalyticsWithOrchestration & {
logEvent: jest.Mock;
} {
return {
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
getDeviceId: jest.fn(() => deviceId),
};
}
function createSessionManager({ loggedOut = false }: { loggedOut?: boolean } = {}): {
sessionManager: SessionManager;
mocked: SessionManager;
} {
const mocked = mock<SessionManager>();
when(mocked.getAccessToken()).thenReturn(null);
when(mocked.getSessionSecret()).thenReturn(loggedOut ? null : 'secret');
when(mocked.getUserAsync(anything())).thenResolve(loggedOut ? undefined : actor);
return { sessionManager: instance(mocked), mocked };
}
function options(overrides: Partial<ContextOptions> = {}): ContextOptions {
return {
nonInteractive: false,
sessionManager: createSessionManager().sessionManager,
analytics: createAnalytics(),
...overrides,
} as ContextOptions;
}
/** Resolves the context and then loads the client, as a command does on its first variant read. */
async function loadClientAsync(
contextOptions: ContextOptions = options()
): Promise<ExperimentationClient> {
const lazyClient = await new ExperimentationContextField().getValueAsync(contextOptions);
return await lazyClient.getClientAsync();
}
async function flushPromisesAsync(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
afterAll(() => {
process.env = ORIGINAL_ENV;
});
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
delete process.env.CI;
delete process.env.EAS_BUILD;
mockEnv.experimentOverride = undefined;
jest.mocked(createGraphqlClient).mockReturnValue(fakeGraphqlClient);
getConfigsAsyncMock.mockResolvedValue(allConfigs);
});
describe(ExperimentationContextField, () => {
it('returns a disabled client and fetches nothing when analytics is disabled', async () => {
const { sessionManager, mocked } = createSessionManager();
const client = await loadClientAsync(
options({ analytics: createAnalytics(null), sessionManager })
);
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(getConfigsAsyncMock).not.toHaveBeenCalled();
expect(createGraphqlClient).not.toHaveBeenCalled();
verify(mocked.getUserAsync(anything())).never();
});
it.each(['CI', 'EAS_BUILD'])(
'returns a disabled client and fetches nothing when %s is set',
async flag => {
process.env[flag] = '1';
const { sessionManager, mocked } = createSessionManager();
const client = await loadClientAsync(options({ sessionManager }));
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(getConfigsAsyncMock).not.toHaveBeenCalled();
expect(createGraphqlClient).not.toHaveBeenCalled();
verify(mocked.getUserAsync(anything())).never();
}
);
it('still honors EAS_EXPERIMENT_OVERRIDE in CI', async () => {
process.env.CI = 'true';
mockEnv.experimentOverride = 'cli-full:variant=treatment';
const client = await loadClientAsync(options());
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('treatment');
expect(getConfigsAsyncMock).not.toHaveBeenCalled();
});
it('still honors EAS_EXPERIMENT_OVERRIDE when analytics is disabled', async () => {
mockEnv.experimentOverride = 'cli-full:variant=treatment';
const client = await loadClientAsync(options({ analytics: createAnalytics(null) }));
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('treatment');
});
it('buckets each scope on its own unit and logs exposures through analytics', async () => {
const analytics = createAnalytics('device-1');
const client = await loadClientAsync(options({ analytics }));
client.getUserNamespace('cli-full').getParam('variant', 'control');
client
.getAccountNamespace('cli-full', { accountId: 'account-1' })
.getParam('variant', 'control');
client.getDeviceNamespace('cli-full').getParam('variant', 'control');
const units = analytics.logEvent.mock.calls.map(([, properties]) => properties.unit);
expect(units).toEqual(['user-1', 'account-1', 'device-1']);
expect(createGraphqlClient).toHaveBeenCalledWith(
{ accessToken: null, sessionSecret: 'secret' },
{ requestTimeoutMs: 3000 }
);
});
it('gives logged-out users defaults for user experiments but still runs device experiments', async () => {
const analytics = createAnalytics('device-1');
const { sessionManager } = createSessionManager({ loggedOut: true });
const client = await loadClientAsync(options({ analytics, sessionManager }));
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(['control', 'treatment']).toContain(
client.getDeviceNamespace('cli-full').getParam('variant', 'x')
);
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
});
it('fails open to defaults when the config fetch rejects', async () => {
getConfigsAsyncMock.mockRejectedValue(new Error('offline'));
const analytics = createAnalytics();
const client = await loadClientAsync(options({ analytics }));
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
expect(Log.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch'),
expect.anything()
);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.warn).not.toHaveBeenCalled();
});
it('returns defaults and logs nothing for empty configs, as the server sends for robots', async () => {
getConfigsAsyncMock.mockResolvedValue({
userConfig: emptyDefinition,
accountConfig: emptyDefinition,
deviceConfig: emptyDefinition,
});
const analytics = createAnalytics();
const client = await loadClientAsync(options({ analytics }));
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('fails open to defaults when the server config is invalid', async () => {
getConfigsAsyncMock.mockResolvedValue({
...allConfigs,
userConfig: {
experiments: TEST_DEFINITION.experiments,
namespaces: [
{
name: 'too-many',
numSegments: 10,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'all', experimentName: 'cli-test', numSegments: 20 },
],
},
],
},
});
const client = await loadClientAsync(options());
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(Log.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to initialize'),
expect.anything()
);
});
it('logs account exposures on the account ID the command passes', async () => {
const analytics = createAnalytics();
const client = await loadClientAsync(options({ analytics }));
client
.getAccountNamespace('cli-full', { accountId: 'picked-account' })
.getParam('variant', 'x');
expect(analytics.logEvent).toHaveBeenCalledWith(
'Experiment Viewed',
expect.objectContaining({ unit: 'picked-account' })
);
});
it('caps the actor lookup with the same timeout as the config fetch', async () => {
const { sessionManager, mocked } = createSessionManager();
await loadClientAsync(options({ sessionManager }));
verify(mocked.getUserAsync(deepEqual({ requestTimeoutMs: 3000 }))).once();
expect(createGraphqlClient).toHaveBeenCalledWith(expect.anything(), { requestTimeoutMs: 3000 });
});
it('runs the actor lookup and the config fetch in parallel', async () => {
let resolveConfigs!: (value: typeof allConfigs) => void;
let resolveActor!: (value: Actor) => void;
getConfigsAsyncMock.mockReturnValue(new Promise(resolve => (resolveConfigs = resolve)));
const mocked = mock<SessionManager>();
when(mocked.getAccessToken()).thenReturn(null);
when(mocked.getSessionSecret()).thenReturn('secret');
when(mocked.getUserAsync(anything())).thenReturn(
new Promise(resolve => (resolveActor = resolve))
);
const pending = loadClientAsync(options({ sessionManager: instance(mocked) }));
await flushPromisesAsync();
expect(getConfigsAsyncMock).toHaveBeenCalledTimes(1);
verify(mocked.getUserAsync(anything())).once();
resolveConfigs(allConfigs);
resolveActor(actor);
await expect(pending).resolves.toBeDefined();
});
it('fetches nothing until a command asks for the client', async () => {
const { sessionManager, mocked } = createSessionManager();
const lazyClient = await new ExperimentationContextField().getValueAsync(
options({ sessionManager })
);
expect(getConfigsAsyncMock).not.toHaveBeenCalled();
expect(createGraphqlClient).not.toHaveBeenCalled();
verify(mocked.getUserAsync(anything())).never();
await lazyClient.getClientAsync();
expect(getConfigsAsyncMock).toHaveBeenCalledTimes(1);
});
it('caches one client per analytics instance so repeated context calls do not log duplicate exposures', async () => {
const field = new ExperimentationContextField();
const analytics = createAnalytics();
const first = await field.getValueAsync(options({ analytics }));
const second = await field.getValueAsync(options({ analytics }));
const other = await field.getValueAsync(options({ analytics: createAnalytics() }));
expect(second).toBe(first);
expect(other).not.toBe(first);
expect(await second.getClientAsync()).toBe(await first.getClientAsync());
expect(await other.getClientAsync()).not.toBe(await first.getClientAsync());
expect(getConfigsAsyncMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,124 @@
import { createRequest } from '@urql/core';
import http from 'http';
import { AddressInfo } from 'net';
import { createGraphqlClient } from '../createGraphqlClient';
jest.mock('../../../../api', () => ({
getExpoApiBaseUrl: () => process.env.CREATE_GRAPHQL_CLIENT_TEST_URL,
}));
// A plain string keeps this document out of GraphQL codegen, which scans gql tags in src/**.
const TEST_QUERY = 'query CreateGraphqlClientTest { __typename }';
function fetchOptionsOf(client: ReturnType<typeof createGraphqlClient>): RequestInit {
const operation = client.createRequestOperation('query', createRequest(TEST_QUERY, {}));
const { fetchOptions } = operation.context;
return typeof fetchOptions === 'function' ? fetchOptions() : (fetchOptions ?? {});
}
/** Serves one GraphQL response after `delayMs`. Returns the base URL. */
async function startSlowServerAsync(server: http.Server, delayMs: number): Promise<string> {
server.on('request', (_req, res) => {
setTimeout(() => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ data: { __typename: 'RootQuery' } }));
}, delayMs);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}`;
}
describe(createGraphqlClient, () => {
it('sends the access token as a bearer header', () => {
const init = fetchOptionsOf(createGraphqlClient({ accessToken: 'token', sessionSecret: null }));
expect(init.headers).toEqual({ authorization: 'Bearer token' });
});
it('sends the session secret when there is no access token', () => {
const init = fetchOptionsOf(
createGraphqlClient({ accessToken: null, sessionSecret: 'secret' })
);
expect(init.headers).toEqual({ 'expo-session': 'secret' });
});
it('keeps the auth headers when a request timeout is set', () => {
const init = fetchOptionsOf(
createGraphqlClient({ accessToken: 'token', sessionSecret: null }, { requestTimeoutMs: 50 })
);
expect(init.headers).toEqual({ authorization: 'Bearer token' });
});
describe('against a slow server', () => {
const server = http.createServer();
const serverDelayMs = 2000;
beforeAll(async () => {
process.env.CREATE_GRAPHQL_CLIENT_TEST_URL = await startSlowServerAsync(
server,
serverDelayMs
);
});
afterAll(async () => {
delete process.env.CREATE_GRAPHQL_CLIENT_TEST_URL;
server.closeAllConnections();
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
it('aborts the request when the timeout elapses', async () => {
// Only a real request proves the timeout works.
const client = createGraphqlClient(
{ accessToken: 'token', sessionSecret: null },
{ requestTimeoutMs: 100 }
);
const start = Date.now();
const result = await client.query(TEST_QUERY, {}, { noRetry: true }).toPromise();
expect(Date.now() - start).toBeLessThan(serverDelayMs);
expect(result.data).toBeUndefined();
expect(result.error?.networkError?.name).toBe('TimeoutError');
expect(result.error?.networkError?.message).toBe('Request timed out after 100 ms');
});
it('waits for the response when no timeout is set', async () => {
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
const result = await client.query(TEST_QUERY, {}, { noRetry: true }).toPromise();
expect(result.error).toBeUndefined();
expect(result.data).toEqual({ __typename: 'RootQuery' });
});
});
describe('against a closed port', () => {
beforeAll(async () => {
// Listen once to reserve a free port, then close so the port refuses connections.
const server = http.createServer();
process.env.CREATE_GRAPHQL_CLIENT_TEST_URL = await startSlowServerAsync(server, 0);
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
afterAll(() => {
delete process.env.CREATE_GRAPHQL_CLIENT_TEST_URL;
});
it('passes through fetch errors that are not timeouts', async () => {
const client = createGraphqlClient(
{ accessToken: 'token', sessionSecret: null },
{ requestTimeoutMs: 5000 }
);
const result = await client.query(TEST_QUERY, {}, { noRetry: true }).toPromise();
expect(result.data).toBeUndefined();
expect(result.error?.networkError).toBeDefined();
expect(result.error?.networkError?.name).not.toBe('TimeoutError');
expect(result.error?.networkError?.message).toContain('ECONNREFUSED');
});
});
});
@@ -1,6 +1,10 @@
import { Client, cacheExchange, createClient as createUrqlClient, fetchExchange } from '@urql/core';
import { retryExchange } from '@urql/exchange-retry';
import fetch from 'node-fetch';
import fetch, {
RequestInfo as NodeFetchRequestInfo,
RequestInit as NodeFetchRequestInit,
Response as NodeFetchResponse,
} from 'node-fetch';
import { getExpoApiBaseUrl } from '../../../api';
import { httpsProxyAgent } from '../../../fetch';
@@ -8,10 +12,46 @@ import { httpsProxyAgent } from '../../../fetch';
// Extend the urql Client to allow for future customizations or type additions ahead of upstream type support.
export interface ExpoGraphqlClient extends Client {}
export function createGraphqlClient(authInfo: {
accessToken: string | null;
sessionSecret: string | null;
}): ExpoGraphqlClient {
export function createGraphqlClient(
authInfo: {
accessToken: string | null;
sessionSecret: string | null;
},
options: {
/** Request timeout. Kept at the client level: a per-operation `fetchOptions` in urql would replace the auth headers. */
requestTimeoutMs?: number;
} = {}
): ExpoGraphqlClient {
const { requestTimeoutMs } = options;
// urql overwrites `fetchOptions.signal`, so the timeout must be added inside fetch.
const fetchWithTimeoutAsync = async (
url: NodeFetchRequestInfo,
init?: NodeFetchRequestInit
): Promise<NodeFetchResponse> => {
if (!requestTimeoutMs) {
return await fetch(url, init);
}
const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
try {
return await fetch(url, {
...init,
signal: AbortSignal.any([
...(init?.signal ? [init.signal as AbortSignal] : []),
timeoutSignal,
]),
});
} catch (error) {
if (timeoutSignal.aborted) {
const timeoutError = new Error(`Request timed out after ${requestTimeoutMs} ms`, {
cause: error,
});
timeoutError.name = 'TimeoutError';
throw timeoutError;
}
throw error;
}
};
return createUrqlClient({
url: getExpoApiBaseUrl() + '/graphql',
exchanges: [
@@ -29,7 +69,7 @@ export function createGraphqlClient(authInfo: {
fetchExchange,
],
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
fetch,
fetch: fetchWithTimeoutAsync,
fetchOptions: (): RequestInit => {
const headers: Record<string, string> = {};
if (authInfo.accessToken) {
@@ -28,6 +28,7 @@ const sessionManager = new SessionManager({
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
getDeviceId: jest.fn(),
});
const originalIsTTY = process.stdin.isTTY;
const originalCI = process.env.CI;
+2 -1
View File
@@ -4,7 +4,8 @@ import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static override description =
'display or change analytics settings (disabling analytics also disables experiments)';
static override args = {
STATUS: Args.string({
@@ -140,6 +140,7 @@ export default class Build extends EasCommand {
...this.ContextOptions.ProjectDir,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
...this.ContextOptions.Experimentation,
};
async runAsync(): Promise<void> {
@@ -156,6 +157,7 @@ export default class Build extends EasCommand {
projectDir,
analytics,
vcsClient,
experimentation,
} = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
@@ -182,6 +184,7 @@ export default class Build extends EasCommand {
flags: flagsWithPlatform,
actor,
getDynamicPrivateProjectConfigAsync,
experimentation,
});
this.maybeSuggestUsingEasBuildDev({
@@ -0,0 +1,154 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
import {
getServeSimDeviceUdidAsync,
runServeSimHostActionAsync,
} from '../../../simulator/serveSimHost';
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/serveSimHost', () => ({
...jest.requireActual('../../../simulator/serveSimHost'),
getServeSimDeviceUdidAsync: jest.fn(),
runServeSimHostActionAsync: jest.fn(),
}));
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
const SPRINGBOARD_RESTART = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.Argent,
platform: AppPlatform.Ios,
remoteConfig: {
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: 'preview-token',
},
...overrides,
} as DeviceRunSessionById;
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(parseSimctlArgv, () => {
it('reads --id before the simctl arguments', () => {
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
id: 'session-1',
args: ['ui', 'appearance', 'dark'],
});
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
id: 'session-1',
args: SPRINGBOARD_RESTART,
});
});
it('passes simctl flags through untouched', () => {
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
});
});
describe(SimulatorSimctl, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
mockByIdAsync.mockResolvedValue(makeSession());
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
});
function createCommand(argv: string[]): SimulatorSimctl {
const command = new SimulatorSimctl(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir: '/test/project',
});
return command;
}
it('restarts SpringBoard on the session simulator', async () => {
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
'springboard.restart',
{ udid: UDID }
);
});
it('sets the appearance on the session from the env file', async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
await createCommand(['ui', 'appearance', 'dark']).runAsync();
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
expect.anything(),
'appearance.set',
{ value: 'dark', udid: UDID }
);
});
it('rejects an unsupported command before fetching the session', async () => {
await expect(
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('rejects an Android session', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('only supports iOS');
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
});
it('rejects a session that is not running', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('is stopped, not running');
});
});
@@ -0,0 +1,108 @@
import EasCommand from '../../commandUtils/EasCommand';
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../../simulator/serveSimHost';
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
let id: string | undefined;
let index = 0;
while (index < rawArgv.length) {
const arg = rawArgv[index];
if (arg === '--') {
index += 1;
break;
}
if (arg === '--id') {
id = rawArgv[index + 1];
if (!id) {
throw new Error('--id needs a simulator session ID.');
}
index += 2;
} else if (arg.startsWith('--id=')) {
id = arg.slice('--id='.length);
index += 1;
} else {
break;
}
}
return { id, args: rawArgv.slice(index) };
}
export default class SimulatorSimctl extends EasCommand {
static override aliases = ['sim:simctl'];
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
command => `\`${command}\``
).join(', ')}`;
static override strict = false;
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
await this.parse(SimulatorSimctl, []);
const { id: flagId, args } = parseSimctlArgv(rawArgv);
const hostAction = resolveSimctlHostAction(args);
if (!hostAction) {
throw new Error(
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
command => ` ${command}`
).join('\n')}`
);
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
await loadSimulatorEnvAsync(projectDir);
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
if (!sessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
if (session.platform !== AppPlatform.Ios) {
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
}
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
);
}
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
if (!host) {
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
}
const udid = await getServeSimDeviceUdidAsync(host);
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
...hostAction.params,
udid,
});
if (stdout) {
Log.log(stdout.trimEnd());
}
if (stderr) {
Log.warn(stderr.trimEnd());
}
}
}
+6
View File
@@ -21,4 +21,10 @@ export default {
* Comma separated list of feature gate keys of feature gates to evaluate override to false.
*/
featureGateDisable: process.env.EAS_FG_DISABLE,
/**
* Comma separated list of experiment overrides in the form `namespace:param=value`.
* Overrides apply in every scope, even when analytics is disabled. A namespace with any override
* returns defaults for its other params and logs no exposure.
*/
experimentOverride: process.env.EAS_EXPERIMENT_OVERRIDE,
};
@@ -0,0 +1,18 @@
import type { ExposureData, ExposureLogger } from '@expo/experimentation';
import { Analytics, ExperimentationEvent } from '../analytics/AnalyticsManager';
export class AnalyticsExposureLogger implements ExposureLogger<Record<string, any>> {
constructor(private readonly analytics: Analytics) {}
public logExposure(data: ExposureData<Record<string, any>>): void {
const { variant } = data.params;
this.analytics.logEvent(ExperimentationEvent.EXPERIMENT_VIEWED, {
experimentName: data.name,
// Analytics properties cannot be undefined. Fall back to all params when there is no variant.
variationName: variant !== undefined ? String(variant) : JSON.stringify(data.params),
// The unit is always a single value (actor ID, account ID, or device ID).
unit: String(data.unit[0]),
});
}
}
@@ -0,0 +1,79 @@
import type { Namespace } from '@expo/experimentation';
import { ExperimentOverrides } from './ExperimentOverrides';
import Log from '../log';
export type ExperimentNamespace = {
getParam<T>(paramName: string, defaultValue: T): T;
isOverridden: boolean;
};
export const FALLBACK_NAMESPACE: ExperimentNamespace = {
getParam(_paramName, defaultValue) {
return defaultValue;
},
isOverridden: false,
};
/** Serves overrides without consulting the real namespace, so no exposure is logged. */
export class OverrideFallbackNamespace implements ExperimentNamespace {
public readonly isOverridden = true;
constructor(
private readonly namespaceName: string,
private readonly overrides: ExperimentOverrides
) {}
public getParam<T>(paramName: string, defaultValue: T): T {
if (this.overrides.hasOverride(this.namespaceName, paramName)) {
return coerceOverrideValue(
this.overrides.getOverride(this.namespaceName, paramName),
defaultValue
);
}
return defaultValue;
}
}
/** Returns the default instead of throwing when the library rejects a param on first read. */
export class SafeNamespace implements ExperimentNamespace {
public readonly isOverridden = false;
constructor(
private readonly namespace: Namespace,
private readonly namespaceName: string
) {}
public getParam<T>(paramName: string, defaultValue: T): T {
try {
return this.namespace.getParam(paramName, defaultValue);
} catch (error) {
Log.debug(
`[experimentation] Failed to read param "${paramName}" from namespace "${this.namespaceName}". Returning the default.`,
error
);
return defaultValue;
}
}
}
export function coerceOverrideValue<T>(overrideValue: string | undefined, defaultValue: T): T {
if (overrideValue === undefined || overrideValue.trim() === '') {
return defaultValue;
}
if (typeof defaultValue === 'boolean') {
const normalized = overrideValue.toLowerCase();
if (normalized === 'true') {
return true as T;
}
if (normalized === 'false') {
return false as T;
}
return defaultValue;
}
if (typeof defaultValue === 'number') {
const parsed = Number(overrideValue);
return (Number.isFinite(parsed) ? parsed : defaultValue) as T;
}
return overrideValue as T;
}
@@ -0,0 +1,73 @@
import env from '../env';
export type ExperimentOverrideMap = ReadonlyMap<string, ReadonlyMap<string, string>>;
/**
* Parses `EAS_EXPERIMENT_OVERRIDE`: comma-separated `namespace:param=value` entries. Values may
* contain `:` and `=` but not `,`. Overrides are keyed by namespace name and apply in every scope.
*
* @example EAS_EXPERIMENT_OVERRIDE="onboarding:variant=treatment,cli-hints:enabled=true"
*/
export function parseExperimentOverrides(rawValue: string | undefined): ExperimentOverrideMap {
const overrideMap = new Map<string, Map<string, string>>();
if (!rawValue) {
return overrideMap;
}
for (const entry of rawValue.split(',')) {
const override = entry.trim();
if (!override) {
continue;
}
const firstEqualsIndex = override.indexOf('=');
if (firstEqualsIndex === -1) {
continue;
}
const namespaceAndParam = override.slice(0, firstEqualsIndex);
const value = override.slice(firstEqualsIndex + 1).trim();
const firstColonIndex = namespaceAndParam.indexOf(':');
if (firstColonIndex === -1) {
continue;
}
const namespace = namespaceAndParam.slice(0, firstColonIndex).trim();
const param = namespaceAndParam.slice(firstColonIndex + 1).trim();
if (!namespace || !param) {
continue;
}
let params = overrideMap.get(namespace);
if (!params) {
params = new Map();
overrideMap.set(namespace, params);
}
params.set(param, value);
}
return overrideMap;
}
export class ExperimentOverrides {
constructor(private readonly overrideMap: ExperimentOverrideMap) {}
public static fromEnv(): ExperimentOverrides {
return new ExperimentOverrides(parseExperimentOverrides(env.experimentOverride));
}
public static empty(): ExperimentOverrides {
return new ExperimentOverrides(new Map());
}
public hasNamespaceOverride(namespace: string): boolean {
return this.overrideMap.has(namespace);
}
public hasOverride(namespace: string, param: string): boolean {
return this.overrideMap.get(namespace)?.has(param) ?? false;
}
public getOverride(namespace: string, param: string): string | undefined {
return this.overrideMap.get(namespace)?.get(param);
}
}
@@ -0,0 +1,175 @@
import {
type ExperimentUnit,
type ExperimentationDefinition,
type LoggerConfig,
type Namespace,
NoOpExposureLogger,
parseExperimentationConfig,
} from '@expo/experimentation';
import { createHash } from 'node:crypto';
import { AnalyticsExposureLogger } from './AnalyticsExposureLogger';
import {
type ExperimentNamespace,
FALLBACK_NAMESPACE,
OverrideFallbackNamespace,
SafeNamespace,
} from './ExperimentNamespace';
import { ExperimentOverrides } from './ExperimentOverrides';
import { Analytics } from '../analytics/AnalyticsManager';
import Log from '../log';
export type ExperimentationScope = 'user' | 'account' | 'device';
export type ExperimentationConfigs = Record<ExperimentationScope, ExperimentationDefinition>;
/** Commands pass the account unit on each `getAccountNamespace` call. */
export type ExperimentationUnits = Record<Exclude<ExperimentationScope, 'account'>, string | null>;
export const EMPTY_EXPERIMENTATION_DEFINITION: ExperimentationDefinition = {
experiments: [],
namespaces: [],
};
const EMPTY_CONFIGS: ExperimentationConfigs = {
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: EMPTY_EXPERIMENTATION_DEFINITION,
device: EMPTY_EXPERIMENTATION_DEFINITION,
};
const NULL_UNITS: ExperimentationUnits = { user: null, device: null };
function sha1(input: string): string {
return createHash('sha1').update(input).digest('hex');
}
export function createLoggerConfig(analytics: Analytics): LoggerConfig {
return {
flavorMap: new Map([
['no-op', new NoOpExposureLogger()],
['rudderstack', new AnalyticsExposureLogger(analytics)],
]),
fallbackLoggerType: 'no-op',
};
}
const NO_OP_LOGGER_CONFIG: LoggerConfig = {
flavorMap: new Map([['no-op', new NoOpExposureLogger()]]),
fallbackLoggerType: 'no-op',
};
/** Cached per (unit, name): the library deduplicates exposures per Namespace instance. */
class ScopedNamespaces {
private readonly factories: ReadonlyMap<string, (unit: ExperimentUnit) => Namespace>;
private readonly instances = new Map<string, Map<string, Namespace>>();
constructor(
definition: ExperimentationDefinition,
loggerConfig: LoggerConfig,
defaultUnit: string | null
) {
this.factories = parseExperimentationConfig(definition, { sha1Fn: sha1 }, loggerConfig);
// Build every namespace now so an invalid server config throws here, in the fail-open path.
if (defaultUnit) {
for (const name of this.factories.keys()) {
this.getNamespace(name, defaultUnit);
}
}
}
public getNamespace(name: string, unit: string): Namespace | null {
const factory = this.factories.get(name);
if (!factory) {
return null;
}
let byName = this.instances.get(unit);
if (!byName) {
byName = new Map();
this.instances.set(unit, byName);
}
let namespace = byName.get(name);
if (!namespace) {
namespace = factory([unit]);
byName.set(name, namespace);
}
return namespace;
}
}
/**
* Resolves experiment params for the current CLI run. Units: user is the actor ID, account is the
* ID the command passes to `getAccountNamespace`, device is the analytics device ID.
* User and account units are shared across surfaces. The device unit is specific to the CLI, so a
* device namespace must not be shared with another surface.
* The first `getParam` on a namespace logs an exposure, so call it only where the variant is used.
*/
export class ExperimentationClient {
private readonly scopes: Record<ExperimentationScope, ScopedNamespaces>;
constructor(
configs: ExperimentationConfigs,
private readonly units: ExperimentationUnits,
private readonly overrides: ExperimentOverrides,
loggerConfig: LoggerConfig
) {
this.scopes = {
user: new ScopedNamespaces(configs.user, loggerConfig, units.user),
account: new ScopedNamespaces(configs.account, loggerConfig, null),
device: new ScopedNamespaces(configs.device, loggerConfig, units.device),
};
}
/** Env overrides still apply, so QA can force a variant even with analytics disabled. */
public static createDisabled(
overrides: ExperimentOverrides = ExperimentOverrides.fromEnv()
): ExperimentationClient {
return new ExperimentationClient(EMPTY_CONFIGS, NULL_UNITS, overrides, NO_OP_LOGGER_CONFIG);
}
public getUserNamespace(name: string): ExperimentNamespace {
return this.resolve('user', name, this.units.user);
}
/** Takes the account the command already resolved, so no extra lookup or config read happens here. */
public getAccountNamespace(
name: string,
{ accountId }: { accountId: string }
): ExperimentNamespace {
return this.resolve('account', name, accountId);
}
public getDeviceNamespace(name: string): ExperimentNamespace {
return this.resolve('device', name, this.units.device);
}
private resolve(
scope: ExperimentationScope,
name: string,
unit: string | null
): ExperimentNamespace {
if (this.overrides.hasNamespaceOverride(name)) {
return new OverrideFallbackNamespace(name, this.overrides);
}
if (!unit) {
return FALLBACK_NAMESPACE;
}
let namespace: Namespace | null;
try {
namespace = this.scopes[scope].getNamespace(name, unit);
} catch (error) {
Log.debug(
`[experimentation] Failed to build namespace "${name}" in the ${scope} config. Returning defaults.`,
error
);
return FALLBACK_NAMESPACE;
}
if (!namespace) {
Log.debug(
`[experimentation] Namespace "${name}" not found in the ${scope} config. Returning defaults.`
);
return FALLBACK_NAMESPACE;
}
return new SafeNamespace(namespace, name);
}
}
@@ -0,0 +1,20 @@
import { ExperimentationClient } from './ExperimentationClient';
/**
* Defers building the experimentation client, and the config fetch behind it, until a command
* first needs a variant.
*/
export class LazyExperimentationClient {
private pending: Promise<ExperimentationClient> | null = null;
constructor(private readonly loadAsync: () => Promise<ExperimentationClient>) {}
public static fromClient(client: ExperimentationClient): LazyExperimentationClient {
return new LazyExperimentationClient(async () => client);
}
public async getClientAsync(): Promise<ExperimentationClient> {
this.pending ??= this.loadAsync();
return await this.pending;
}
}
@@ -0,0 +1,124 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import UserSettings from '../../user/UserSettings';
import { createAnalyticsAsync } from '../../analytics/AnalyticsManager';
import { AnalyticsExposureLogger } from '../AnalyticsExposureLogger';
import { createMockAnalytics } from './fixtures';
const mockIdentify = jest.fn();
const mockTrack = jest.fn();
const mockFlush = jest.fn();
jest.mock('@expo/rudder-sdk-node', () =>
jest.fn().mockImplementation(() => ({
identify: mockIdentify,
track: mockTrack,
flush: mockFlush,
}))
);
jest.mock('../../user/UserSettings', () => ({
__esModule: true,
default: {
deleteKeyAsync: jest.fn(),
getAsync: jest.fn(),
setAsync: jest.fn(),
},
}));
jest.mock('../../analytics/agent', () => ({ getAgentTelemetryContext: jest.fn() }));
jest.mock('../../analytics/sandbox', () => ({ getSandboxTelemetryContext: jest.fn() }));
const originalDisableEasAnalytics = process.env.DISABLE_EAS_ANALYTICS;
const originalHttpsProxy = process.env.https_proxy;
afterAll(() => {
if (originalDisableEasAnalytics === undefined) {
delete process.env.DISABLE_EAS_ANALYTICS;
} else {
process.env.DISABLE_EAS_ANALYTICS = originalDisableEasAnalytics;
}
if (originalHttpsProxy === undefined) {
delete process.env.https_proxy;
} else {
process.env.https_proxy = originalHttpsProxy;
}
});
const exposure = {
name: 'cli-test',
time: 1700000000,
salt: 'cli-full.cli-test.cli-test',
};
describe(AnalyticsExposureLogger, () => {
it('logs Experiment Viewed with experimentName, variationName, and unit', () => {
const analytics = createMockAnalytics();
new AnalyticsExposureLogger(analytics).logExposure({
...exposure,
unit: ['account-1'],
params: { variant: 'treatment' },
});
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: 'cli-test',
variationName: 'treatment',
unit: 'account-1',
});
});
it('never sends an undefined variationName', () => {
const analytics = createMockAnalytics();
new AnalyticsExposureLogger(analytics).logExposure({
...exposure,
unit: ['account-1'],
params: { enabled: true, count: 2 },
});
expect(analytics.logEvent).toHaveBeenCalledWith(
'Experiment Viewed',
expect.objectContaining({ variationName: JSON.stringify({ enabled: true, count: 2 }) })
);
});
it('sends numeric units and variants as strings', () => {
const analytics = createMockAnalytics();
new AnalyticsExposureLogger(analytics).logExposure({
...exposure,
unit: [42],
params: { variant: 3 },
});
expect(analytics.logEvent).toHaveBeenCalledWith(
'Experiment Viewed',
expect.objectContaining({ unit: '42', variationName: '3' })
);
});
it('reaches RudderStack with the CLI source and anonymous ID', async () => {
jest.mocked(RudderAnalytics).mockClear();
mockTrack.mockClear();
jest
.mocked(UserSettings.getAsync)
.mockImplementation(async (key, defaultValue) =>
key === 'analyticsDeviceId' ? 'persistent-device-id' : defaultValue
);
delete process.env.DISABLE_EAS_ANALYTICS;
delete process.env.https_proxy;
const analytics = await createAnalyticsAsync();
new AnalyticsExposureLogger(analytics).logExposure({
...exposure,
unit: ['account-1'],
params: { variant: 'treatment' },
});
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
event: 'Experiment Viewed',
anonymousId: 'persistent-device-id',
properties: expect.objectContaining({
experimentName: 'cli-test',
variationName: 'treatment',
unit: 'account-1',
source: 'eas cli',
}),
})
);
});
});
@@ -0,0 +1,74 @@
import type { Namespace } from '@expo/experimentation';
import Log from '../../log';
import {
FALLBACK_NAMESPACE,
OverrideFallbackNamespace,
SafeNamespace,
coerceOverrideValue,
} from '../ExperimentNamespace';
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
jest.mock('../../log');
describe(coerceOverrideValue, () => {
it('coerces booleans only from "true" and "false"', () => {
expect(coerceOverrideValue('false', true)).toBe(false);
expect(coerceOverrideValue('TRUE', false)).toBe(true);
expect(coerceOverrideValue('1', false)).toBe(false);
expect(coerceOverrideValue('yes', true)).toBe(true);
});
it('coerces finite numbers and falls back otherwise', () => {
expect(coerceOverrideValue('2', 1)).toBe(2);
expect(coerceOverrideValue('2.5', 1)).toBe(2.5);
expect(coerceOverrideValue('x', 1)).toBe(1);
expect(coerceOverrideValue('Infinity', 1)).toBe(1);
});
it('passes strings through and returns the default for empty input', () => {
expect(coerceOverrideValue('b', 'a')).toBe('b');
expect(coerceOverrideValue('', 'a')).toBe('a');
expect(coerceOverrideValue(' ', 'a')).toBe('a');
expect(coerceOverrideValue(undefined, 'a')).toBe('a');
});
});
describe('FALLBACK_NAMESPACE', () => {
it('returns the default for every param', () => {
expect(FALLBACK_NAMESPACE.getParam('variant', 'control')).toBe('control');
expect(FALLBACK_NAMESPACE.isOverridden).toBe(false);
});
});
describe(OverrideFallbackNamespace, () => {
const overrides = new ExperimentOverrides(parseExperimentOverrides('ns:variant=treatment'));
const namespace = new OverrideFallbackNamespace('ns', overrides);
it('returns the coerced override for overridden params', () => {
expect(namespace.getParam('variant', 'control')).toBe('treatment');
expect(namespace.isOverridden).toBe(true);
});
it('returns the default for params without an override', () => {
expect(namespace.getParam('enabled', false)).toBe(false);
});
});
describe(SafeNamespace, () => {
it('delegates to the wrapped namespace', () => {
const inner = { getParam: jest.fn().mockReturnValue('treatment') } as unknown as Namespace;
expect(new SafeNamespace(inner, 'ns').getParam('variant', 'control')).toBe('treatment');
expect(inner.getParam).toHaveBeenCalledWith('variant', 'control');
});
it('returns the default and logs at debug level when the wrapped namespace throws', () => {
const inner = {
getParam: jest.fn(() => {
throw new Error('bad operator');
}),
} as unknown as Namespace;
expect(new SafeNamespace(inner, 'ns').getParam('variant', 'control')).toBe('control');
expect(Log.debug).toHaveBeenCalled();
});
});
@@ -0,0 +1,72 @@
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
jest.mock('../../env', () => ({
__esModule: true,
default: { experimentOverride: 'from-env:variant=treatment' },
}));
describe(parseExperimentOverrides, () => {
it('parses a single override', () => {
const map = parseExperimentOverrides('pricing:variant=treatment');
expect(map.get('pricing')?.get('variant')).toBe('treatment');
});
it('parses multiple overrides and multiple params per namespace', () => {
const map = parseExperimentOverrides('a:variant=x,b:limit=3,a:enabled=true');
expect(map.get('a')?.get('variant')).toBe('x');
expect(map.get('a')?.get('enabled')).toBe('true');
expect(map.get('b')?.get('limit')).toBe('3');
});
it('returns an empty map for undefined or empty input', () => {
expect(parseExperimentOverrides(undefined).size).toBe(0);
expect(parseExperimentOverrides('').size).toBe(0);
expect(parseExperimentOverrides(' , ').size).toBe(0);
});
it('skips entries without "=" or ":" and entries with an empty namespace or param', () => {
const map = parseExperimentOverrides(
'bogus,ns:x,variant=1,:variant=1,ns:=1,ok:variant=control'
);
expect([...map.keys()]).toEqual(['ok']);
});
it('keeps "=" and ":" inside values', () => {
const map = parseExperimentOverrides('ns:url=https://expo.dev/a=b');
expect(map.get('ns')?.get('url')).toBe('https://expo.dev/a=b');
});
it('trims whitespace and tolerates a trailing comma', () => {
const map = parseExperimentOverrides(' ns : variant = treatment , ');
expect(map.get('ns')?.get('variant')).toBe('treatment');
expect(map.size).toBe(1);
});
it('lets later duplicates win', () => {
const map = parseExperimentOverrides('ns:variant=a,ns:variant=b');
expect(map.get('ns')?.get('variant')).toBe('b');
});
});
describe(ExperimentOverrides, () => {
it('exposes namespace and param lookups', () => {
const overrides = new ExperimentOverrides(parseExperimentOverrides('ns:variant=treatment'));
expect(overrides.hasNamespaceOverride('ns')).toBe(true);
expect(overrides.hasNamespaceOverride('other')).toBe(false);
expect(overrides.hasOverride('ns', 'variant')).toBe(true);
expect(overrides.hasOverride('ns', 'enabled')).toBe(false);
expect(overrides.hasOverride('other', 'variant')).toBe(false);
expect(overrides.getOverride('ns', 'variant')).toBe('treatment');
expect(overrides.getOverride('ns', 'enabled')).toBeUndefined();
expect(overrides.getOverride('other', 'variant')).toBeUndefined();
});
it('reads EAS_EXPERIMENT_OVERRIDE through env', () => {
const overrides = ExperimentOverrides.fromEnv();
expect(overrides.getOverride('from-env', 'variant')).toBe('treatment');
});
it('empty() has no overrides', () => {
expect(ExperimentOverrides.empty().hasNamespaceOverride('from-env')).toBe(false);
});
});
@@ -0,0 +1,376 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import Log from '../../log';
import {
EMPTY_EXPERIMENTATION_DEFINITION,
ExperimentationClient,
ExperimentationConfigs,
ExperimentationUnits,
createLoggerConfig,
} from '../ExperimentationClient';
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
import { TEST_DEFINITION, createMockAnalytics, findUnitsByValue } from './fixtures';
import { FALLBACK_NAMESPACE } from '../ExperimentNamespace';
jest.mock('../../log');
jest.mock('../../env', () => ({
__esModule: true,
default: { experimentOverride: 'cli-full:variant=from-env' },
}));
function createClient({
configs = {},
units = {},
overrides = ExperimentOverrides.empty(),
analytics = createMockAnalytics(),
}: {
configs?: Partial<ExperimentationConfigs>;
units?: Partial<ExperimentationUnits>;
overrides?: ExperimentOverrides;
analytics?: ReturnType<typeof createMockAnalytics>;
} = {}): { client: ExperimentationClient; analytics: ReturnType<typeof createMockAnalytics> } {
const client = new ExperimentationClient(
{
user: configs.user ?? EMPTY_EXPERIMENTATION_DEFINITION,
account: configs.account ?? EMPTY_EXPERIMENTATION_DEFINITION,
device: configs.device ?? EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: units.user ?? null, device: units.device ?? null },
overrides,
createLoggerConfig(analytics)
);
return { client, analytics };
}
function userClientForUnit(unit: string): ExperimentationClient {
return createClient({ configs: { user: TEST_DEFINITION }, units: { user: unit } }).client;
}
const unitsByVariant = findUnitsByValue(
unit => userClientForUnit(unit).getUserNamespace('cli-full'),
'variant',
'control',
['control', 'treatment']
);
const controlUnit = unitsByVariant.get('control')!;
const treatmentUnit = unitsByVariant.get('treatment')!;
beforeEach(() => {
jest.clearAllMocks();
});
describe(findUnitsByValue, () => {
it('throws when no unit produces an expected value', () => {
expect(() => findUnitsByValue(() => FALLBACK_NAMESPACE, 'variant', 'x', ['never'])).toThrow(
'Could not find units for all of: never'
);
});
});
describe(ExperimentationClient, () => {
describe('assignment', () => {
it('gives the same unit the same variant across clients', () => {
const first = userClientForUnit(treatmentUnit).getUserNamespace('cli-full');
const second = userClientForUnit(treatmentUnit).getUserNamespace('cli-full');
expect(first.getParam('variant', 'control')).toBe('treatment');
expect(second.getParam('variant', 'control')).toBe('treatment');
expect(
userClientForUnit(controlUnit).getUserNamespace('cli-full').getParam('variant', 'x')
).toBe('control');
});
it('returns the default and logs at debug level for an unknown namespace', () => {
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: treatmentUnit },
});
expect(client.getUserNamespace('missing').getParam('variant', 'control')).toBe('control');
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('"missing"'));
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('returns the default and logs no exposure when the scope has no unit', () => {
const { client, analytics } = createClient({ configs: { user: TEST_DEFINITION } });
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
expect(Log.debug).not.toHaveBeenCalled();
});
it('treats an empty string unit like a missing unit', () => {
const { client, analytics } = createClient({ configs: { account: TEST_DEFINITION } });
expect(
client.getAccountNamespace('cli-full', { accountId: '' }).getParam('variant', 'control')
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('buckets account experiments on the account ID the command passes', () => {
const { client, analytics } = createClient({ configs: { account: TEST_DEFINITION } });
expect(
client.getAccountNamespace('cli-full', { accountId: controlUnit }).getParam('variant', 'x')
).toBe('control');
expect(
client
.getAccountNamespace('cli-full', { accountId: treatmentUnit })
.getParam('variant', 'x')
).toBe('treatment');
expect(analytics.logEvent).toHaveBeenCalledTimes(2);
});
it('resolves the same namespace name independently per scope', () => {
const scoped = (choice: string): ExperimentationDefinition => ({
experiments: [
{
experimentName: 'shared',
paramDefinitions: { variant: { type: 'uniform_choice', choices: [choice] } },
exposureLoggerType: 'no-op',
},
],
namespaces: [
{
name: 'shared',
numSegments: 10,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'all', experimentName: 'shared', numSegments: 10 },
],
},
],
});
const { client } = createClient({
configs: {
user: scoped('from-user'),
account: scoped('from-account'),
device: scoped('from-device'),
},
units: { user: 'u', device: 'd' },
});
expect(client.getUserNamespace('shared').getParam('variant', 'x')).toBe('from-user');
expect(
client.getAccountNamespace('shared', { accountId: 'a' }).getParam('variant', 'x')
).toBe('from-account');
expect(client.getDeviceNamespace('shared').getParam('variant', 'x')).toBe('from-device');
});
it('returns the default and logs no exposure for a unit outside the allocated segments', () => {
const unallocated = findUnitsByValue(
unit => userClientForUnit(unit).getUserNamespace('cli-half'),
'variant',
'not-allocated',
['not-allocated']
).get('not-allocated')!;
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: unallocated },
});
expect(client.getUserNamespace('cli-half').getParam('variant', 'control')).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe('exposure logging', () => {
it('logs one exposure per namespace per client', () => {
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: treatmentUnit },
});
const namespace = client.getUserNamespace('cli-full');
namespace.getParam('variant', 'control');
namespace.getParam('enabled', false);
client.getUserNamespace('cli-full').getParam('variant', 'control');
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: 'cli-test',
variationName: 'treatment',
unit: treatmentUnit,
});
});
it('logs again for a second client with the same unit, so clients must be cached per command', () => {
const analytics = createMockAnalytics();
createClient({
configs: { user: TEST_DEFINITION },
units: { user: treatmentUnit },
analytics,
})
.client.getUserNamespace('cli-full')
.getParam('variant', 'control');
createClient({
configs: { user: TEST_DEFINITION },
units: { user: treatmentUnit },
analytics,
})
.client.getUserNamespace('cli-full')
.getParam('variant', 'control');
expect(analytics.logEvent).toHaveBeenCalledTimes(2);
});
it('logs nothing for experiments that use the no-op logger', () => {
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: 'unit-1' },
});
expect(['a', 'b']).toContain(
client.getUserNamespace('cli-quiet-ns').getParam('variant', 'x')
);
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('falls back to the no-op logger for an unknown exposureLoggerType', () => {
const definition: ExperimentationDefinition = {
experiments: [
{
experimentName: 'odd',
paramDefinitions: { variant: { type: 'uniform_choice', choices: ['only'] } },
exposureLoggerType: 'something-else',
},
],
namespaces: [
{
name: 'odd',
numSegments: 10,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'all', experimentName: 'odd', numSegments: 10 },
],
},
],
};
const { client, analytics } = createClient({
configs: { user: definition },
units: { user: 'u' },
});
expect(client.getUserNamespace('odd').getParam('variant', 'x')).toBe('only');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe('overrides', () => {
it('returns the override instead of the assigned variant and logs no exposure', () => {
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: controlUnit },
overrides: new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment')),
});
const namespace = client.getUserNamespace('cli-full');
expect(namespace.isOverridden).toBe(true);
expect(namespace.getParam('variant', 'control')).toBe('treatment');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('returns the default for other params of an overridden namespace without logging an exposure', () => {
const { client, analytics } = createClient({
configs: { user: TEST_DEFINITION },
units: { user: controlUnit },
overrides: new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment')),
});
expect(client.getUserNamespace('cli-full').getParam('enabled', false)).toBe(false);
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('applies overrides to namespaces that are not in the server config', () => {
const { client } = createClient({
overrides: new ExperimentOverrides(
parseExperimentOverrides('not-deployed:variant=treatment')
),
});
expect(client.getUserNamespace('not-deployed').getParam('variant', 'control')).toBe(
'treatment'
);
});
it('applies the same override in every scope', () => {
const { client } = createClient({
overrides: new ExperimentOverrides(parseExperimentOverrides('ns:enabled=true')),
});
expect(client.getUserNamespace('ns').getParam('enabled', false)).toBe(true);
expect(client.getAccountNamespace('ns', { accountId: 'a' }).getParam('enabled', false)).toBe(
true
);
expect(client.getDeviceNamespace('ns').getParam('enabled', false)).toBe(true);
});
});
describe('invalid server config', () => {
it('returns the default without throwing when a param uses an unknown operator', () => {
const definition = {
experiments: [
{
experimentName: 'broken',
paramDefinitions: { variant: { type: 'not-an-operator' } },
exposureLoggerType: 'rudderstack',
},
],
namespaces: [
{
name: 'broken',
numSegments: 10,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'all', experimentName: 'broken', numSegments: 10 },
],
},
],
} as unknown as ExperimentationDefinition;
const { client } = createClient({ configs: { user: definition }, units: { user: 'u' } });
expect(client.getUserNamespace('broken').getParam('variant', 'control')).toBe('control');
expect(Log.debug).toHaveBeenCalled();
});
const overAllocated: ExperimentationDefinition = {
experiments: TEST_DEFINITION.experiments,
namespaces: [
{
name: 'too-many',
numSegments: 10,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'all', experimentName: 'cli-test', numSegments: 20 },
],
},
],
};
it('throws during construction when a namespace over-allocates segments', () => {
expect(() =>
createClient({ configs: { user: overAllocated }, units: { user: 'u' } })
).toThrow(/requested 20 segments/);
});
it('returns the default when a namespace fails to build for an account ID', () => {
// Account namespaces have no default unit, so the failure surfaces on lookup, not construction.
const { client, analytics } = createClient({ configs: { account: overAllocated } });
expect(
client.getAccountNamespace('too-many', { accountId: 'a' }).getParam('variant', 'control')
).toBe('control');
expect(Log.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to build namespace'),
expect.anything()
);
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe('createDisabled', () => {
it('returns defaults for every scope and logs no exposure', () => {
const client = ExperimentationClient.createDisabled(ExperimentOverrides.empty());
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
expect(Log.debug).not.toHaveBeenCalled();
// The account scope always has a unit, so the empty config reports the namespace as missing.
expect(
client.getAccountNamespace('cli-full', { accountId: 'a' }).getParam('variant', 'control')
).toBe('control');
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('not found'));
});
it('reads overrides from the environment by default', () => {
const client = ExperimentationClient.createDisabled();
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('from-env');
});
it('still honors overrides', () => {
const client = ExperimentationClient.createDisabled(
new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment'))
);
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('treatment');
});
});
});
@@ -0,0 +1,79 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { Analytics } from '../../analytics/AnalyticsManager';
import { ExperimentNamespace } from '../ExperimentNamespace';
/**
* - `cli-full`: every unit is in the `cli-test` experiment (50% control, 50% treatment).
* - `cli-half`: half of the units are in `cli-test`, the rest get defaults.
* - `cli-quiet-ns`: experiment with the `no-op` exposure logger.
*/
export const TEST_DEFINITION: ExperimentationDefinition = {
experiments: [
{
experimentName: 'cli-test',
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
enabled: { type: 'bernoulli', p: 0.5 },
},
exposureLoggerType: 'rudderstack',
},
{
experimentName: 'cli-quiet',
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['a', 'b'] },
},
exposureLoggerType: 'no-op',
},
],
namespaces: [
{
name: 'cli-full',
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'cli-test full', experimentName: 'cli-test', numSegments: 1000 },
],
},
{
name: 'cli-half',
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'cli-test half', experimentName: 'cli-test', numSegments: 500 },
],
},
{
name: 'cli-quiet-ns',
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{ method: 'ADD', name: 'cli-quiet full', experimentName: 'cli-quiet', numSegments: 1000 },
],
},
],
};
export function createMockAnalytics(): Analytics & { logEvent: jest.Mock } {
return { logEvent: jest.fn() };
}
/**
* Finds one unit per expected value by probing the real library, instead of hard-coding hashes.
*/
export function findUnitsByValue<T>(
getNamespaceForUnit: (unit: string) => ExperimentNamespace,
paramName: string,
defaultValue: T,
expectedValues: T[]
): Map<T, string> {
const found = new Map<T, string>();
for (let i = 0; i < 1000 && found.size < expectedValues.length; i++) {
const unit = `unit-${i}`;
const value = getNamespaceForUnit(unit).getParam(paramName, defaultValue);
if (expectedValues.includes(value) && !found.has(value)) {
found.set(value, unit);
}
}
if (found.size !== expectedValues.length) {
throw new Error(`Could not find units for all of: ${expectedValues.join(', ')}`);
}
return found;
}
+5
View File
@@ -16113,6 +16113,11 @@ export type EnvironmentVariablesSharedWithSensitiveQueryVariables = Exact<{
export type EnvironmentVariablesSharedWithSensitiveQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null, environments?: Array<any> | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility: EnvironmentVariableVisibility, type: EnvironmentSecretType, valueWithFileContent?: string | null }> } } } };
export type ExperimentationConfigsQueryVariables = Exact<{ [key: string]: never; }>;
export type ExperimentationConfigsQuery = { __typename?: 'RootQuery', experimentation: { __typename?: 'ExperimentationQuery', userConfig: any, accountConfig: any, deviceConfig: any } };
export type FingerprintsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
after?: InputMaybe<Scalars['String']['input']>;
@@ -0,0 +1,66 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { OperationResult } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EMPTY_EXPERIMENTATION_DEFINITION } from '../../experimentation/ExperimentationClient';
import { ExperimentationConfigsQuery, ExperimentationConfigsQueryVariables } from '../generated';
export type ExperimentationConfigsResult = {
userConfig: ExperimentationDefinition;
accountConfig: ExperimentationDefinition;
deviceConfig: ExperimentationDefinition;
};
/** Like `withErrorHandlingAsync`, but never logs. Callers fail open. */
async function quietQueryAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
const { data, error } = await promise;
if (error) {
throw error;
}
if (!data) {
throw new Error('Returned query result data is null!');
}
return data;
}
/** The server sends an untyped JSONObject. Anything but the expected shape means no experiments. */
function toExperimentationDefinition(value: unknown): ExperimentationDefinition {
if (
value &&
typeof value === 'object' &&
Array.isArray((value as { experiments?: unknown }).experiments) &&
Array.isArray((value as { namespaces?: unknown }).namespaces)
) {
return value as ExperimentationDefinition;
}
return EMPTY_EXPERIMENTATION_DEFINITION;
}
export const ExperimentationConfigQuery = {
async getConfigsAsync(graphqlClient: ExpoGraphqlClient): Promise<ExperimentationConfigsResult> {
const data = await quietQueryAsync(
graphqlClient
.query<ExperimentationConfigsQuery, ExperimentationConfigsQueryVariables>(
gql`
query ExperimentationConfigs {
experimentation {
userConfig
accountConfig
deviceConfig
}
}
`,
{},
// A failing network must not delay the command.
{ additionalTypenames: ['ExperimentationQuery'], noRetry: true }
)
.toPromise()
);
return {
userConfig: toExperimentationDefinition(data.experimentation.userConfig),
accountConfig: toExperimentationDefinition(data.experimentation.accountConfig),
deviceConfig: toExperimentationDefinition(data.experimentation.deviceConfig),
};
},
};
@@ -12,7 +12,10 @@ import { AccountFragmentNode } from '../types/Account';
import { MeActorFragmentNode } from '../types/Actor';
export const UserQuery = {
async currentUserAsync(graphqlClient: ExpoGraphqlClient): Promise<CurrentUserQuery['meActor']> {
async currentUserAsync(
graphqlClient: ExpoGraphqlClient,
{ noRetry = false }: { noRetry?: boolean } = {}
): Promise<CurrentUserQuery['meActor']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<CurrentUserQuery>(
@@ -29,6 +32,7 @@ export const UserQuery = {
{},
{
additionalTypenames: ['User', 'SSOUser'],
noRetry,
}
)
.toPromise()
@@ -0,0 +1,89 @@
import { CombinedError } from '@urql/core';
import { print } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../../log';
import { ExperimentationConfigQuery } from '../ExperimentationConfigQuery';
jest.mock('../../../log');
const definition = {
experiments: [{ experimentName: 'x', paramDefinitions: {}, exposureLoggerType: 'rudderstack' }],
namespaces: [],
};
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
query: jest.Mock;
} {
return {
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
}
describe('ExperimentationConfigQuery.getConfigsAsync', () => {
it('returns the three configs', async () => {
const graphqlClient = makeGraphqlClient({
data: {
experimentation: {
userConfig: definition,
accountConfig: definition,
deviceConfig: definition,
},
},
});
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).resolves.toEqual({
userConfig: definition,
accountConfig: definition,
deviceConfig: definition,
});
});
it('replaces malformed configs with an empty definition', async () => {
const graphqlClient = makeGraphqlClient({
data: {
experimentation: {
userConfig: { experiments: 'nope', namespaces: [] },
accountConfig: null,
deviceConfig: definition,
},
},
});
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).resolves.toEqual({
userConfig: { experiments: [], namespaces: [] },
accountConfig: { experiments: [], namespaces: [] },
deviceConfig: definition,
});
});
it('disables retries and selects only the config fields', async () => {
const graphqlClient = makeGraphqlClient({
data: { experimentation: { userConfig: {}, accountConfig: {}, deviceConfig: {} } },
});
await ExperimentationConfigQuery.getConfigsAsync(graphqlClient);
const [document, variables, context] = graphqlClient.query.mock.calls[0];
const printed = print(document);
expect(printed).toContain('experimentation');
expect(printed).toContain('userConfig');
expect(printed).toContain('accountConfig');
expect(printed).toContain('deviceConfig');
expect(printed).not.toContain('deviceExperimentationUnit');
expect(variables).toEqual({});
expect(context).toEqual({ additionalTypenames: ['ExperimentationQuery'], noRetry: true });
});
it('rejects on a GraphQL error without logging', async () => {
const error = new CombinedError({ networkError: new Error('offline') });
const graphqlClient = makeGraphqlClient({ error });
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).rejects.toBe(error);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.warn).not.toHaveBeenCalled();
});
it('rejects when the result has no data', async () => {
const graphqlClient = makeGraphqlClient({ data: null });
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).rejects.toThrow(
'Returned query result data is null!'
);
});
});
@@ -0,0 +1,74 @@
import http from 'http';
import { AddressInfo } from 'net';
import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { UserQuery } from '../UserQuery';
jest.mock('../../../api', () => ({
getExpoApiBaseUrl: () => process.env.USER_QUERY_TEST_URL,
}));
describe(UserQuery.currentUserAsync, () => {
describe('against a slow server', () => {
const server = http.createServer();
const serverDelayMs = 2000;
const requestTimeoutMs = 100;
let requestCount = 0;
// Aborted requests leave their response timers pending; clear them so the worker can exit.
const responseTimers: NodeJS.Timeout[] = [];
beforeAll(async () => {
server.on('request', (_req, res) => {
requestCount += 1;
responseTimers.push(
setTimeout(() => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ data: { meActor: null } }));
}, serverDelayMs)
);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
process.env.USER_QUERY_TEST_URL = `http://127.0.0.1:${port}`;
});
beforeEach(() => {
requestCount = 0;
});
afterAll(async () => {
delete process.env.USER_QUERY_TEST_URL;
for (const timer of responseTimers) {
clearTimeout(timer);
}
server.closeAllConnections();
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
it('fails after one timed-out request when retries are disabled', async () => {
const client = createGraphqlClient(
{ accessToken: 'token', sessionSecret: null },
{ requestTimeoutMs }
);
await expect(UserQuery.currentUserAsync(client, { noRetry: true })).rejects.toThrow(
`Request timed out after ${requestTimeoutMs} ms`
);
expect(requestCount).toBe(1);
});
it('retries a timed-out request by default', async () => {
const client = createGraphqlClient(
{ accessToken: 'token', sessionSecret: null },
{ requestTimeoutMs }
);
await expect(UserQuery.currentUserAsync(client)).rejects.toThrow(
`Request timed out after ${requestTimeoutMs} ms`
);
expect(requestCount).toBe(2);
}, 15000);
});
});
@@ -0,0 +1,162 @@
import { AddressInfo } from 'net';
import nock from 'nock';
import { WebSocketServer } from 'ws';
import {
UnsupportedServeSimHostActionError,
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../serveSimHost';
import { DeviceRunSessionRemoteConfig } from '../utils';
const TOKEN = 'preview-token';
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
describe(getServeSimHost, () => {
it('reads the web preview host of a controller session', () => {
expect(
getServeSimHost({
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('reads the preview host of a serve-sim session', () => {
expect(
getServeSimHost({
__typename: 'ServeSimRunSessionRemoteConfig',
previewApiUrl: 'https://preview.example.com',
previewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('returns null before the session reports its preview host', () => {
expect(
getServeSimHost({
__typename: 'AgentDeviceRunSessionRemoteConfig',
previewApiUrl: null,
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toBeNull();
});
});
describe(getServeSimDeviceUdidAsync, () => {
afterEach(() => {
nock.cleanAll();
});
it('reads the device from the preview state with the session token', async () => {
nock('https://preview.example.com', {
reqheaders: { authorization: `Bearer ${TOKEN}` },
})
.get('/api')
.reply(200, { device: UDID, port: 3200 });
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).resolves.toBe(UDID);
});
it('fails when no simulator is running', async () => {
nock('https://preview.example.com').get('/api').reply(200, 'null');
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).rejects.toThrow('no running simulator');
});
});
describe(runServeSimHostActionAsync, () => {
let server: WebSocketServer;
let apiUrl: string;
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
let protocols: string[];
let requests: unknown[];
beforeEach(async () => {
protocols = [];
requests = [];
server = new WebSocketServer({
port: 0,
path: '/exec-ws',
handleProtocols: offered => {
protocols = [...offered];
return offered.values().next().value ?? false;
},
});
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', data => {
const request = JSON.parse(data.toString());
requests.push(request);
socket.send(JSON.stringify(reply(request)));
});
});
await new Promise<void>(resolve => {
server.once('listening', resolve);
});
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
it('runs one action over the token-authenticated exec channel', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).resolves.toEqual({ stdout: '', stderr: '' });
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
expect(requests).toEqual([
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
]);
});
it('reports a rejected session token', async () => {
server.removeAllListeners('connection');
server.on('connection', socket => {
socket.close();
});
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('rejected the session token');
});
it('reports an action the session does not have yet', async () => {
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
).rejects.toThrow(UnsupportedServeSimHostActionError);
});
it('reports a failed command with its stderr', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('appearance.set failed: Invalid device');
});
});
@@ -0,0 +1,43 @@
import { resolveSimctlHostAction } from '../simctl';
describe(resolveSimctlHostAction, () => {
it('maps the appearance command', () => {
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
action: 'appearance.set',
params: { value: 'dark' },
});
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
action: 'appearance.set',
params: { value: 'light' },
});
});
it('maps the SpringBoard restart', () => {
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
])
).toEqual({ action: 'springboard.restart', params: {} });
});
it('rejects every other command', () => {
expect(resolveSimctlHostAction([])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.backboardd',
])
).toBeNull();
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
});
});
@@ -0,0 +1,133 @@
import WebSocket from 'ws';
import fetch, { httpsProxyAgent } from '../fetch';
import { DeviceRunSessionRemoteConfig } from './utils';
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
export type ServeSimHost = {
apiUrl: string;
token: string;
};
export type ServeSimHostActionResult = {
stdout: string;
stderr: string;
};
export class UnsupportedServeSimHostActionError extends Error {}
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
switch (remoteConfig.__typename) {
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.previewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
: null;
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
case 'AppiumRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
: null;
}
}
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
headers: { Authorization: `Bearer ${host.token}` },
});
const state = (await response.json()) as { device?: unknown } | null;
if (typeof state?.device !== 'string' || state.device.length === 0) {
throw new Error('The simulator session has no running simulator.');
}
return state.device;
}
export async function runServeSimHostActionAsync(
host: ServeSimHost,
action: string,
params: Record<string, unknown>,
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
): Promise<ServeSimHostActionResult> {
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
const socket = new WebSocket(
url.toString(),
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
{
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
}
);
let settled = false;
let sent = false;
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
socket.terminate();
if (error) {
reject(error);
} else {
resolve(result!);
}
};
const timer = setTimeout(() => {
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
}, timeoutMs);
socket.on('error', error => {
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
});
socket.on('close', () => {
finish(
new Error(
sent
? `The simulator host closed the connection before ${action} finished.`
: 'The simulator host rejected the session token.'
)
);
});
socket.on('message', data => {
let reply: any;
try {
reply = JSON.parse(data.toString());
} catch {
finish(new Error('The simulator host sent an invalid response.'));
return;
}
if (reply?.ready === true && !sent) {
sent = true;
socket.send(JSON.stringify({ id: 1, action, params }));
return;
}
if (reply?.id !== 1 || !sent) {
return;
}
if (typeof reply.error === 'string') {
finish(
reply.error === `unknown action ${action}`
? new UnsupportedServeSimHostActionError(
`This simulator session does not support ${action} yet. Start a new session to use it.`
)
: new Error(`${action} failed: ${reply.error}`)
);
return;
}
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
if (reply.exitCode !== 0) {
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
return;
}
finish(null, { stdout, stderr });
});
});
}
function resolveServeSimUrl(apiUrl: string, route: string): URL {
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
}
+34
View File
@@ -0,0 +1,34 @@
export type SimctlHostAction = {
action: string;
params: Record<string, unknown>;
};
const SPRINGBOARD_RESTART_ARGS = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
export const SUPPORTED_SIMCTL_COMMANDS = [
'ui appearance <light|dark>',
SPRINGBOARD_RESTART_ARGS.join(' '),
];
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
const value = args[2];
if (value === 'light' || value === 'dark') {
return { action: 'appearance.set', params: { value } };
}
return null;
}
if (
args.length === SPRINGBOARD_RESTART_ARGS.length &&
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
) {
return { action: 'springboard.restart', params: {} };
}
return null;
}
+12 -2
View File
@@ -87,13 +87,23 @@ export default class SessionManager {
await this.setSessionAsync(undefined);
}
public async getUserAsync(): Promise<Actor | undefined> {
/**
* @param requestTimeoutMs caps the actor lookup when no actor is cached yet. Callers that must
* not delay a command past a deadline pass it; the default has no timeout. A capped lookup
* also skips the retry exchange, since a retry would double the wait.
*/
public async getUserAsync({ requestTimeoutMs }: { requestTimeoutMs?: number } = {}): Promise<
Actor | undefined
> {
if (!this.currentActor && (this.getAccessToken() || this.getSessionSecret())) {
const authenticationInfo = {
accessToken: this.getAccessToken(),
sessionSecret: this.getSessionSecret(),
};
const actor = await UserQuery.currentUserAsync(createGraphqlClient(authenticationInfo));
const actor = await UserQuery.currentUserAsync(
createGraphqlClient(authenticationInfo, { requestTimeoutMs }),
{ noRetry: requestTimeoutMs !== undefined }
);
this.currentActor = actor ?? undefined;
if (actor) {
this.analytics.setActor(actor);
@@ -5,6 +5,8 @@ import path from 'path';
import { ApiV2Error } from '../../ApiV2Error';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ApiV2Client } from '../../api';
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UserQuery } from '../../graphql/queries/UserQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getStateJsonPath } from '../../utils/paths';
@@ -17,16 +19,17 @@ jest.mock('../../log');
jest.mock('fs');
jest.mock('../../graphql/queries/UserQuery', () => ({
UserQuery: {
currentUserAsync: async () => ({
currentUserAsync: jest.fn(async () => ({
__typename: 'User',
username: 'USERNAME',
id: 'USER_ID',
}),
})),
},
}));
jest.mock('../fetchSessionSecretAndUser');
jest.mock('../fetchSessionSecretAndUserFromBrowserAuthFlow');
jest.mock('../../api');
jest.mock('../../commandUtils/context/contextUtils/createGraphqlClient');
const authStub: any = {
sessionSecret: 'SESSION_SECRET',
@@ -41,6 +44,7 @@ const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn((): void => {}),
setActor: jest.fn((): void => {}),
flushAsync: jest.fn(async (): Promise<void> => {}),
getDeviceId: jest.fn((): string | null => null),
};
beforeEach(() => {
@@ -131,6 +135,36 @@ describe(SessionManager, () => {
});
expect(await sessionManager.getUserAsync()).toMatchObject({ __typename: 'User' });
});
it('creates the GraphQL client without a timeout and keeps retries by default', async () => {
process.env.EXPO_TOKEN = 'accesstoken';
const sessionManager = new SessionManager(analytics);
await sessionManager.getUserAsync();
expect(createGraphqlClient).toHaveBeenCalledWith(
{ accessToken: 'accesstoken', sessionSecret: null },
{ requestTimeoutMs: undefined }
);
expect(jest.mocked(UserQuery.currentUserAsync).mock.calls[0][1]).toEqual({ noRetry: false });
});
it('forwards the request timeout to the GraphQL client and disables retries', async () => {
process.env.EXPO_TOKEN = 'accesstoken';
const sessionManager = new SessionManager(analytics);
await sessionManager.getUserAsync({ requestTimeoutMs: 1234 });
expect(createGraphqlClient).toHaveBeenCalledWith(
{ accessToken: 'accesstoken', sessionSecret: null },
{ requestTimeoutMs: 1234 }
);
expect(jest.mocked(UserQuery.currentUserAsync).mock.calls[0][1]).toEqual({ noRetry: true });
});
it('skips the request once an actor is cached', async () => {
process.env.EXPO_TOKEN = 'accesstoken';
const sessionManager = new SessionManager(analytics);
await sessionManager.getUserAsync();
await sessionManager.getUserAsync({ requestTimeoutMs: 1234 });
expect(createGraphqlClient).toHaveBeenCalledTimes(1);
});
});
describe('loginAsync', () => {
@@ -18,7 +18,12 @@ const now = Date.parse('2026-09-29T12:00:00Z');
let directory: string;
function manager(): SessionManager {
return new SessionManager({ logEvent: jest.fn(), setActor: jest.fn(), flushAsync: jest.fn() });
return new SessionManager({
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
getDeviceId: jest.fn(),
});
}
async function startAsync(): Promise<string> {
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-json
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.1.0",
"version": "25.1.1",
"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": "25.1.0",
"@expo/eas-build-job": "25.1.1",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# eas-cli-local-build-plugin
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.1.0",
"version": "25.1.1",
"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": "25.1.0",
"@expo/build-tools": "25.1.1",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.1.0",
"@expo/logger": "25.1.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.1.0",
"@expo/turtle-spawn": "25.1.1",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# @expo/logger
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "25.1.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/steps
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "25.1.0",
"version": "25.1.1",
"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": "25.1.0",
"@expo/logger": "25.1.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+4
View File
@@ -1,5 +1,9 @@
# @expo/template-file
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more