mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e | ||
|
|
aa9f5370d4 | ||
|
|
60a0b483eb | ||
|
|
070f15c633 | ||
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Show EAS Simulator minutes in `eas account:usage`.
|
||||
@@ -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`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Report Google Play error responses to Sentry so unexplained submission failures can be classified.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host.
|
||||
@@ -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.
|
||||
@@ -22,4 +22,3 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
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
|
||||
|
||||
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
|
||||
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.0.0",
|
||||
"PRODUCTION": "25.0.0"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @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
|
||||
|
||||
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.0.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.0.0",
|
||||
"@expo/eas-build-job": "25.0.0",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.0.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.0.0",
|
||||
"@expo/template-file": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.0.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
@@ -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
|
||||
+20
-1
@@ -108,7 +108,11 @@ describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync(publicPreview?: boolean, frameAncestors?: string[]) {
|
||||
async function runAsync(
|
||||
publicPreview?: boolean,
|
||||
frameAncestors?: string[],
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
@@ -121,6 +125,7 @@ describe.each([
|
||||
{
|
||||
public_preview: { value: publicPreview },
|
||||
frame_ancestors: { value: frameAncestors },
|
||||
cors_origins: { value: corsOrigins },
|
||||
} as Record<string, { value: unknown }>,
|
||||
{
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
@@ -196,6 +201,20 @@ describe.each([
|
||||
);
|
||||
});
|
||||
|
||||
it('maps cors_origins to the host and defaults to none', async () => {
|
||||
await runAsync(undefined, undefined, ['https://app.example.test']);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockClear();
|
||||
await runAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ corsOrigins: [] })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
|
||||
+25
-1
@@ -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',
|
||||
|
||||
+297
-23
@@ -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);
|
||||
|
||||
+318
-75
@@ -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);
|
||||
@@ -89,6 +122,7 @@ function runStep(
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -102,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,
|
||||
@@ -133,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',
|
||||
@@ -158,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]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,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({
|
||||
@@ -214,6 +284,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
@@ -224,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);
|
||||
|
||||
@@ -236,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 () => {
|
||||
@@ -277,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();
|
||||
});
|
||||
|
||||
@@ -322,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'
|
||||
);
|
||||
|
||||
@@ -362,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 () => {
|
||||
@@ -419,6 +626,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -452,6 +660,41 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: ['https://app.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
+24
@@ -446,6 +446,30 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('continues the session when disabling the video watermark fails', async () => {
|
||||
const error = new Error('bun x failed');
|
||||
jest
|
||||
|
||||
+12
@@ -211,6 +211,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
packageVersion: '1.2.3',
|
||||
disableShare: false,
|
||||
frameAncestors: [],
|
||||
corsOrigins: [],
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
@@ -260,6 +261,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
@@ -334,6 +336,16 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands cors_origins to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: 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,
|
||||
@@ -99,6 +96,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
@@ -114,10 +112,23 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
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}).`
|
||||
);
|
||||
@@ -128,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.
|
||||
@@ -156,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,
|
||||
@@ -172,14 +187,21 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
disableShare,
|
||||
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}).`
|
||||
@@ -187,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({
|
||||
@@ -215,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
@@ -250,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,
|
||||
@@ -294,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-'));
|
||||
@@ -303,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,14 +15,15 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -31,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';
|
||||
@@ -44,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({
|
||||
@@ -125,6 +119,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -167,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(
|
||||
{
|
||||
@@ -176,108 +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),
|
||||
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]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
@@ -80,6 +82,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -196,6 +199,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
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),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
@@ -92,6 +94,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -293,6 +296,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
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),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -49,6 +51,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -108,6 +111,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
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),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+10
-18
@@ -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]);
|
||||
|
||||
+33
-55
@@ -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' } }));
|
||||
|
||||
+69
-105
@@ -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 () => {
|
||||
|
||||
@@ -874,7 +874,7 @@ it.each([
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('lets the session add frame ancestors after the website origins', async () => {
|
||||
it('lets the session add frame ancestors and CORS origins after the website origins', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
@@ -882,10 +882,11 @@ it('lets the session add frame ancestors after the website origins', async () =>
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
|
||||
corsOrigins: ['https://app.example.test'],
|
||||
});
|
||||
const { args } = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(args.join(' ')).toContain(
|
||||
'--frame-ancestor https://expo.dev --frame-ancestor https://embed.example.test --frame-ancestor https://*.example.test'
|
||||
'--frame-ancestor https://expo.dev --frame-ancestor https://embed.example.test --frame-ancestor https://*.example.test --cors-origin https://app.example.test'
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
@@ -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,7 +26,9 @@ 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,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
spawnDetached,
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
|
||||
import {
|
||||
EXPO_DEVICE_HUB_STATE_DIR,
|
||||
corsOriginArgs,
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
frameAncestorArgs,
|
||||
@@ -151,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 {
|
||||
@@ -356,6 +357,44 @@ describe(parseFrameAncestorsInput, () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(createCorsOriginsInputProvider, () => {
|
||||
it('accepts an optional JSON list of origins from the session job', () => {
|
||||
const input = createCorsOriginsInputProvider()(createGlobalContextMock(), 'step-id');
|
||||
expect(input).toMatchObject({
|
||||
id: 'cors_origins',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseCorsOriginsInput, () => {
|
||||
it('defaults to no extra origins and returns the origins as given', () => {
|
||||
expect(parseCorsOriginsInput(undefined)).toEqual([]);
|
||||
expect(parseCorsOriginsInput(['https://app.example.test'])).toEqual([
|
||||
'https://app.example.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects anything but a list of non-empty strings', () => {
|
||||
expect(() => parseCorsOriginsInput(['https://app.example.test', ''])).toThrow(
|
||||
'Input "cors_origins" must be an array of non-empty strings, one origin each.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(corsOriginArgs, () => {
|
||||
it('repeats the flag once per origin', () => {
|
||||
expect(corsOriginArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
|
||||
'--cors-origin',
|
||||
'https://a.example.test',
|
||||
'--cors-origin',
|
||||
'https://b.example.test',
|
||||
]);
|
||||
expect(corsOriginArgs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(frameAncestorArgs, () => {
|
||||
it('repeats the flag once per origin', () => {
|
||||
expect(frameAncestorArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
|
||||
@@ -540,13 +579,16 @@ describe(createServeSimArgs, () => {
|
||||
port: 4321,
|
||||
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
|
||||
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
});
|
||||
expect(args.slice(-6)).toEqual([
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/preview-id',
|
||||
]);
|
||||
@@ -754,12 +796,15 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
port: 4321,
|
||||
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
|
||||
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
expect(args.slice(-6)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1051,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: [
|
||||
@@ -1548,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(),
|
||||
});
|
||||
|
||||
@@ -1580,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 },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1592,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, () => {
|
||||
@@ -1616,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({
|
||||
@@ -1625,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,
|
||||
@@ -1645,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.'
|
||||
);
|
||||
@@ -1654,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,
|
||||
@@ -1676,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({
|
||||
@@ -1695,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,
|
||||
@@ -1708,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({
|
||||
@@ -1722,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.'
|
||||
@@ -1734,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({
|
||||
@@ -1747,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.'
|
||||
@@ -1755,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' }
|
||||
);
|
||||
@@ -1776,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 () => {
|
||||
@@ -1795,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({
|
||||
@@ -1818,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();
|
||||
|
||||
@@ -1839,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;
|
||||
|
||||
@@ -107,6 +107,11 @@ export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
|
||||
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
// Both hosts take the same flag and match the same origin shapes.
|
||||
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
|
||||
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
@@ -120,6 +125,7 @@ export function createServeSimArgs({
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
shareUrl,
|
||||
disableShare = false,
|
||||
packageVersion,
|
||||
@@ -135,6 +141,7 @@ export function createServeSimArgs({
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
shareUrl?: string;
|
||||
disableShare?: boolean;
|
||||
packageVersion?: string;
|
||||
@@ -166,6 +173,7 @@ export function createServeSimArgs({
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(disableShare ? ['--disable-share'] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
@@ -188,6 +196,7 @@ export function createExpoDeviceHubArgs({
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
@@ -195,6 +204,7 @@ export function createExpoDeviceHubArgs({
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
@@ -225,6 +235,7 @@ export function createExpoDeviceHubArgs({
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -366,6 +377,8 @@ type DeviceSessionHostOptions = {
|
||||
privatePreviewUrl?: string;
|
||||
/** Origins allowed to embed the preview host, in addition to the website origins. */
|
||||
frameAncestors?: string[];
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
@@ -417,6 +430,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors = [],
|
||||
corsOrigins = [],
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
@@ -478,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
|
||||
@@ -489,6 +503,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
@@ -497,6 +512,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
iosSimulatorUdid,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -896,6 +797,27 @@ export function createFrameAncestorsInputProvider(): ReturnType<
|
||||
}
|
||||
|
||||
export function parseFrameAncestorsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'frame_ancestors', 'EAS_FRAME_ANCESTORS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* Origins whose pages may call the preview host's API cross-origin, such as a page that renders
|
||||
* the device with @expo/hub-client. Framing alone does not need it. The host refuses values that
|
||||
* are not an origin or a subdomain wildcard, so this parser only guards the input shape.
|
||||
*/
|
||||
export function createCorsOriginsInputProvider(): ReturnType<typeof BuildStepInput.createProvider> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'cors_origins',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCorsOriginsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'cors_origins', 'EAS_CORS_ORIGINS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
function parseOriginListInput(value: unknown, inputId: string, errorCode: string): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
@@ -904,8 +826,8 @@ export function parseFrameAncestorsInput(value: unknown): string[] {
|
||||
!value.every(origin => typeof origin === 'string' && origin.trim().length > 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_FRAME_ANCESTORS_INVALID_INPUT',
|
||||
'Input "frame_ancestors" must be an array of non-empty strings, one origin each.'
|
||||
errorCode,
|
||||
`Input "${inputId}" must be an array of non-empty strings, one origin each.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.0.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.0.0"
|
||||
"@expo/steps": "25.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.0.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",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.0.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.0.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
|
||||
- Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
+181
-148
File diff suppressed because it is too large.
Load diff
Generated
+40
@@ -45707,6 +45707,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "corsOrigins",
|
||||
"description": "Origins of pages that may call the session's preview host API cross-origin, for example to\nrender the device with @expo/hub-client. Framing the preview does not need this. Same value\nrules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.\nIf omitted, only expo.dev may call the preview API cross-origin.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egress",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure.",
|
||||
@@ -45731,6 +45751,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "frameAncestors",
|
||||
"description": "Origins of pages that may embed the session's preview host in a frame, next to expo.dev.\nEach value is an https origin such as https://app.example.com or a subdomain wildcard such\nas https://*.example.com; plain http is accepted for localhost only. At most 10. The\nembedding page still needs the preview token from remoteConfig, and whoever embeds the\npreview can also control the device. If omitted, only expo.dev may embed the preview.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ios",
|
||||
"description": "iOS Simulator options. Only supported when platform is IOS.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.0.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.0.0",
|
||||
"@expo/eas-json": "25.0.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.0.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.0.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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+289
@@ -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);
|
||||
});
|
||||
});
|
||||
+124
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1967,6 +1967,146 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends frame ancestors and prints the embed URL', async () => {
|
||||
const remoteConfig = {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-123',
|
||||
webPreviewToken: 'preview-secret',
|
||||
previewApiUrl: 'https://preview-api.example.test',
|
||||
};
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
|
||||
);
|
||||
const frameAncestorFlags = [
|
||||
'--frame-ancestor',
|
||||
'https://Embed.example.test/',
|
||||
'--frame-ancestor',
|
||||
'https://*.example.test',
|
||||
];
|
||||
|
||||
const { command: jsonCommand } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
...frameAncestorFlags,
|
||||
]);
|
||||
await jsonCommand.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
publicPreview: false,
|
||||
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
|
||||
})
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: deviceRunSessionPreviewUrl,
|
||||
previewApiUrl: 'https://preview-api.example.test/?token=preview-secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
|
||||
);
|
||||
const { command: humanCommand } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
...frameAncestorFlags,
|
||||
]);
|
||||
await humanCommand.runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://preview-api.example.test/?token=preview-secret')
|
||||
);
|
||||
});
|
||||
|
||||
it('sends CORS origins and prints the preview API URL', async () => {
|
||||
const remoteConfig = {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-123',
|
||||
webPreviewToken: 'preview-secret',
|
||||
previewApiUrl: 'https://preview-api.example.test',
|
||||
};
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
|
||||
);
|
||||
|
||||
const { command: jsonCommand } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
'--cors-origin',
|
||||
'https://App.example.test/',
|
||||
]);
|
||||
await jsonCommand.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ frameAncestors: expect.anything() })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
previewApiUrl: 'https://preview-api.example.test/?token=preview-secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
|
||||
);
|
||||
const { command: humanCommand } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
]);
|
||||
await humanCommand.runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://preview-api.example.test/?token=preview-secret')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a CORS origin with a path before creating a session', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--cors-origin',
|
||||
'https://app.example.test/app',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--cors-origin https://app.example.test/app must be an origin only'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a frame ancestor that is not an origin before creating a session', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test/embed',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow('must be an origin only');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'uses the %s public-link choice in start JSON and human output',
|
||||
async publicLink => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import Log, { link } from '../../log';
|
||||
import { Ora, ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import { parseCorsOriginList, parseFrameAncestorList } from '../../simulator/frameAncestors';
|
||||
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
@@ -177,6 +178,16 @@ export default class Simulator extends EasCommand {
|
||||
multiple: true,
|
||||
dependsOn: ['egress'],
|
||||
}),
|
||||
'cors-origin': Flags.string({
|
||||
description:
|
||||
'Origin of a page that may call the simulator preview API cross-origin, for example to render the device with @expo/hub-client, such as https://app.example.com or https://*.example.com. Repeat for multiple origins. Embedding the preview in a frame does not need this; use --frame-ancestor for that. The page still needs the printed preview URL and its token.',
|
||||
multiple: true,
|
||||
}),
|
||||
'frame-ancestor': Flags.string({
|
||||
description:
|
||||
'Origin of a page that may embed the simulator preview in a frame, such as https://app.example.com or https://*.example.com. Repeat for multiple origins. The embedding page loads the printed preview URL, which carries the session token, so anyone who can read that page can also control the simulator.',
|
||||
multiple: true,
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
@@ -286,6 +297,18 @@ export default class Simulator extends EasCommand {
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
let frameAncestors: string[] = [];
|
||||
try {
|
||||
frameAncestors = parseFrameAncestorList(flags['frame-ancestor'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
let corsOrigins: string[] = [];
|
||||
try {
|
||||
corsOrigins = parseCorsOriginList(flags['cors-origin'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (flags['network-capture'] && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError(
|
||||
'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.'
|
||||
@@ -358,6 +381,8 @@ export default class Simulator extends EasCommand {
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(resourceClass ? { resourceClass } : {}),
|
||||
...(egress ? { egress } : {}),
|
||||
...(frameAncestors.length ? { frameAncestors } : {}),
|
||||
...(corsOrigins.length ? { corsOrigins } : {}),
|
||||
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
|
||||
...agentIdentity,
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
@@ -510,7 +535,8 @@ export default class Simulator extends EasCommand {
|
||||
remoteConfig: sanitizeRemoteConfigForJson(
|
||||
remoteConfig,
|
||||
deviceRunSessionPreviewUrl,
|
||||
flags['public-link']
|
||||
flags['public-link'],
|
||||
{ frameAncestors, corsOrigins }
|
||||
),
|
||||
});
|
||||
return;
|
||||
@@ -524,6 +550,8 @@ export default class Simulator extends EasCommand {
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
privatePreviewUrl: deviceRunSessionPreviewUrl,
|
||||
publicLink: flags['public-link'],
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+20
@@ -6418,6 +6418,14 @@ export type CreateDeviceRunSessionInput = {
|
||||
* and applicationArchiveUrl.
|
||||
*/
|
||||
expoGo?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/**
|
||||
* Origins of pages that may embed the session's preview host in a frame, next to expo.dev.
|
||||
* Each value is an https origin such as https://app.example.com or a subdomain wildcard such
|
||||
* as https://*.example.com; plain http is accepted for localhost only. At most 10. The
|
||||
* embedding page still needs the preview token from remoteConfig, and whoever embeds the
|
||||
* preview can also control the device. If omitted, only expo.dev may embed the preview.
|
||||
*/
|
||||
frameAncestors?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** iOS Simulator options. Only supported when platform is IOS. */
|
||||
ios?: InputMaybe<CreateIosDeviceRunSessionInput>;
|
||||
/**
|
||||
@@ -6556,6 +6564,13 @@ export type CreateGitHubRepositorySettingsInput = {
|
||||
};
|
||||
|
||||
export type CreateIosDeviceRunSessionInput = {
|
||||
/**
|
||||
* Origins of pages that may call the session's preview host API cross-origin, for example to
|
||||
* render the device with @expo/hub-client. Framing the preview does not need this. Same value
|
||||
* rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.
|
||||
* If omitted, only expo.dev may call the preview API cross-origin.
|
||||
*/
|
||||
corsOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
|
||||
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -16098,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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user