mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e |
No files matched your search
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -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.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.0.0",
|
||||
"PRODUCTION": "25.0.0"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+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);
|
||||
|
||||
+306
-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);
|
||||
@@ -103,22 +136,32 @@ function runStep(
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
await tasks.untilAborted(runDevicePreparationAsync(device));
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
@@ -134,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
).toBeLessThan(
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
@@ -159,28 +211,45 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
application: expect.any(Promise),
|
||||
});
|
||||
await step;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
it('shuts down only the selected guarded Booted device before host startup', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'SELECTED-UDID',
|
||||
env: {},
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
|
||||
env: {},
|
||||
logger: expect.any(Object),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -191,7 +260,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, sessionCtx);
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -215,6 +284,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
@@ -225,7 +295,96 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
expect('iosSimulatorUdid' in device).toBe(true);
|
||||
if (!('iosSimulatorUdid' in device)) {
|
||||
return;
|
||||
}
|
||||
await hostReady.promise;
|
||||
deviceLaunch = await device.application;
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
hostReady.resolve();
|
||||
await step;
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases guard reporting and egress resources when host startup fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
@@ -237,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
@@ -278,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -323,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
@@ -363,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
it('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels and drains a pending download through the external signal', async () => {
|
||||
const controller = new AbortController();
|
||||
const download = mockStalledDownload();
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await failure;
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
selected.resolve();
|
||||
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
@@ -464,6 +670,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['argument count', { launch_args: Array(257).fill('a') }],
|
||||
['argument length', { launch_args: ['a'.repeat(9000)] }],
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
* Serve-sim boots while the iOS download runs. After device preparation verifies
|
||||
* any egress guard, its control API installs and launches the app.
|
||||
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -117,9 +114,21 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
| {
|
||||
iosSimulatorUdid: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -130,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
const externallyBootedDevice = 'booted' in device ? device : undefined;
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
@@ -158,12 +169,14 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
@@ -175,14 +188,20 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
@@ -190,18 +209,20 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -218,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
@@ -253,7 +275,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
@@ -297,6 +319,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
signal?: AbortSignal;
|
||||
secrets?: string[];
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
signal?.throwIfAborted();
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
@@ -306,6 +329,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
@@ -33,6 +32,10 @@ import {
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -46,26 +49,15 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
|
||||
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
|
||||
* session, so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -170,6 +162,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -179,109 +174,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/eas-json": "25.1.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../../simulator/serveSimHost';
|
||||
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/serveSimHost', () => ({
|
||||
...jest.requireActual('../../../simulator/serveSimHost'),
|
||||
getServeSimDeviceUdidAsync: jest.fn(),
|
||||
runServeSimHostActionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
const SPRINGBOARD_RESTART = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
|
||||
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
|
||||
|
||||
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.Argent,
|
||||
platform: AppPlatform.Ios,
|
||||
remoteConfig: {
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: 'preview-token',
|
||||
},
|
||||
...overrides,
|
||||
} as DeviceRunSessionById;
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(parseSimctlArgv, () => {
|
||||
it('reads --id before the simctl arguments', () => {
|
||||
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
|
||||
id: 'session-1',
|
||||
args: ['ui', 'appearance', 'dark'],
|
||||
});
|
||||
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
|
||||
id: 'session-1',
|
||||
args: SPRINGBOARD_RESTART,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes simctl flags through untouched', () => {
|
||||
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
|
||||
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe(SimulatorSimctl, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeSession());
|
||||
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
|
||||
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorSimctl {
|
||||
const command = new SimulatorSimctl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('restarts SpringBoard on the session simulator', async () => {
|
||||
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
|
||||
'springboard.restart',
|
||||
{ udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the appearance on the session from the env file', async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
|
||||
|
||||
await createCommand(['ui', 'appearance', 'dark']).runAsync();
|
||||
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'appearance.set',
|
||||
{ value: 'dark', udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsupported command before fetching the session', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
|
||||
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an Android session', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('only supports iOS');
|
||||
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('is stopped, not running');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../simulator/serveSimHost';
|
||||
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
|
||||
|
||||
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
|
||||
let id: string | undefined;
|
||||
let index = 0;
|
||||
while (index < rawArgv.length) {
|
||||
const arg = rawArgv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (arg === '--id') {
|
||||
id = rawArgv[index + 1];
|
||||
if (!id) {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
index += 2;
|
||||
} else if (arg.startsWith('--id=')) {
|
||||
id = arg.slice('--id='.length);
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { id, args: rawArgv.slice(index) };
|
||||
}
|
||||
|
||||
export default class SimulatorSimctl extends EasCommand {
|
||||
static override aliases = ['sim:simctl'];
|
||||
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => `\`${command}\``
|
||||
).join(', ')}`;
|
||||
static override strict = false;
|
||||
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const rawArgv = [...this.argv];
|
||||
await this.parse(SimulatorSimctl, []);
|
||||
const { id: flagId, args } = parseSimctlArgv(rawArgv);
|
||||
|
||||
const hostAction = resolveSimctlHostAction(args);
|
||||
if (!hostAction) {
|
||||
throw new Error(
|
||||
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => ` ${command}`
|
||||
).join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
|
||||
}
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
|
||||
);
|
||||
}
|
||||
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
|
||||
if (!host) {
|
||||
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
|
||||
}
|
||||
|
||||
const udid = await getServeSimDeviceUdidAsync(host);
|
||||
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
|
||||
...hostAction.params,
|
||||
udid,
|
||||
});
|
||||
if (stdout) {
|
||||
Log.log(stdout.trimEnd());
|
||||
}
|
||||
if (stderr) {
|
||||
Log.warn(stderr.trimEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,10 @@ export default {
|
||||
* Comma separated list of feature gate keys of feature gates to evaluate override to false.
|
||||
*/
|
||||
featureGateDisable: process.env.EAS_FG_DISABLE,
|
||||
/**
|
||||
* Comma separated list of experiment overrides in the form `namespace:param=value`.
|
||||
* Overrides apply in every scope, even when analytics is disabled. A namespace with any override
|
||||
* returns defaults for its other params and logs no exposure.
|
||||
*/
|
||||
experimentOverride: process.env.EAS_EXPERIMENT_OVERRIDE,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ExposureData, ExposureLogger } from '@expo/experimentation';
|
||||
|
||||
import { Analytics, ExperimentationEvent } from '../analytics/AnalyticsManager';
|
||||
|
||||
export class AnalyticsExposureLogger implements ExposureLogger<Record<string, any>> {
|
||||
constructor(private readonly analytics: Analytics) {}
|
||||
|
||||
public logExposure(data: ExposureData<Record<string, any>>): void {
|
||||
const { variant } = data.params;
|
||||
this.analytics.logEvent(ExperimentationEvent.EXPERIMENT_VIEWED, {
|
||||
experimentName: data.name,
|
||||
// Analytics properties cannot be undefined. Fall back to all params when there is no variant.
|
||||
variationName: variant !== undefined ? String(variant) : JSON.stringify(data.params),
|
||||
// The unit is always a single value (actor ID, account ID, or device ID).
|
||||
unit: String(data.unit[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Namespace } from '@expo/experimentation';
|
||||
|
||||
import { ExperimentOverrides } from './ExperimentOverrides';
|
||||
import Log from '../log';
|
||||
|
||||
export type ExperimentNamespace = {
|
||||
getParam<T>(paramName: string, defaultValue: T): T;
|
||||
isOverridden: boolean;
|
||||
};
|
||||
|
||||
export const FALLBACK_NAMESPACE: ExperimentNamespace = {
|
||||
getParam(_paramName, defaultValue) {
|
||||
return defaultValue;
|
||||
},
|
||||
isOverridden: false,
|
||||
};
|
||||
|
||||
/** Serves overrides without consulting the real namespace, so no exposure is logged. */
|
||||
export class OverrideFallbackNamespace implements ExperimentNamespace {
|
||||
public readonly isOverridden = true;
|
||||
|
||||
constructor(
|
||||
private readonly namespaceName: string,
|
||||
private readonly overrides: ExperimentOverrides
|
||||
) {}
|
||||
|
||||
public getParam<T>(paramName: string, defaultValue: T): T {
|
||||
if (this.overrides.hasOverride(this.namespaceName, paramName)) {
|
||||
return coerceOverrideValue(
|
||||
this.overrides.getOverride(this.namespaceName, paramName),
|
||||
defaultValue
|
||||
);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the default instead of throwing when the library rejects a param on first read. */
|
||||
export class SafeNamespace implements ExperimentNamespace {
|
||||
public readonly isOverridden = false;
|
||||
|
||||
constructor(
|
||||
private readonly namespace: Namespace,
|
||||
private readonly namespaceName: string
|
||||
) {}
|
||||
|
||||
public getParam<T>(paramName: string, defaultValue: T): T {
|
||||
try {
|
||||
return this.namespace.getParam(paramName, defaultValue);
|
||||
} catch (error) {
|
||||
Log.debug(
|
||||
`[experimentation] Failed to read param "${paramName}" from namespace "${this.namespaceName}". Returning the default.`,
|
||||
error
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function coerceOverrideValue<T>(overrideValue: string | undefined, defaultValue: T): T {
|
||||
if (overrideValue === undefined || overrideValue.trim() === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
const normalized = overrideValue.toLowerCase();
|
||||
if (normalized === 'true') {
|
||||
return true as T;
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false as T;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof defaultValue === 'number') {
|
||||
const parsed = Number(overrideValue);
|
||||
return (Number.isFinite(parsed) ? parsed : defaultValue) as T;
|
||||
}
|
||||
return overrideValue as T;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import env from '../env';
|
||||
|
||||
export type ExperimentOverrideMap = ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
|
||||
/**
|
||||
* Parses `EAS_EXPERIMENT_OVERRIDE`: comma-separated `namespace:param=value` entries. Values may
|
||||
* contain `:` and `=` but not `,`. Overrides are keyed by namespace name and apply in every scope.
|
||||
*
|
||||
* @example EAS_EXPERIMENT_OVERRIDE="onboarding:variant=treatment,cli-hints:enabled=true"
|
||||
*/
|
||||
export function parseExperimentOverrides(rawValue: string | undefined): ExperimentOverrideMap {
|
||||
const overrideMap = new Map<string, Map<string, string>>();
|
||||
if (!rawValue) {
|
||||
return overrideMap;
|
||||
}
|
||||
|
||||
for (const entry of rawValue.split(',')) {
|
||||
const override = entry.trim();
|
||||
if (!override) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstEqualsIndex = override.indexOf('=');
|
||||
if (firstEqualsIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const namespaceAndParam = override.slice(0, firstEqualsIndex);
|
||||
const value = override.slice(firstEqualsIndex + 1).trim();
|
||||
|
||||
const firstColonIndex = namespaceAndParam.indexOf(':');
|
||||
if (firstColonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const namespace = namespaceAndParam.slice(0, firstColonIndex).trim();
|
||||
const param = namespaceAndParam.slice(firstColonIndex + 1).trim();
|
||||
if (!namespace || !param) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = overrideMap.get(namespace);
|
||||
if (!params) {
|
||||
params = new Map();
|
||||
overrideMap.set(namespace, params);
|
||||
}
|
||||
params.set(param, value);
|
||||
}
|
||||
|
||||
return overrideMap;
|
||||
}
|
||||
|
||||
export class ExperimentOverrides {
|
||||
constructor(private readonly overrideMap: ExperimentOverrideMap) {}
|
||||
|
||||
public static fromEnv(): ExperimentOverrides {
|
||||
return new ExperimentOverrides(parseExperimentOverrides(env.experimentOverride));
|
||||
}
|
||||
|
||||
public static empty(): ExperimentOverrides {
|
||||
return new ExperimentOverrides(new Map());
|
||||
}
|
||||
|
||||
public hasNamespaceOverride(namespace: string): boolean {
|
||||
return this.overrideMap.has(namespace);
|
||||
}
|
||||
|
||||
public hasOverride(namespace: string, param: string): boolean {
|
||||
return this.overrideMap.get(namespace)?.has(param) ?? false;
|
||||
}
|
||||
|
||||
public getOverride(namespace: string, param: string): string | undefined {
|
||||
return this.overrideMap.get(namespace)?.get(param);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
type ExperimentUnit,
|
||||
type ExperimentationDefinition,
|
||||
type LoggerConfig,
|
||||
type Namespace,
|
||||
NoOpExposureLogger,
|
||||
parseExperimentationConfig,
|
||||
} from '@expo/experimentation';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { AnalyticsExposureLogger } from './AnalyticsExposureLogger';
|
||||
import {
|
||||
type ExperimentNamespace,
|
||||
FALLBACK_NAMESPACE,
|
||||
OverrideFallbackNamespace,
|
||||
SafeNamespace,
|
||||
} from './ExperimentNamespace';
|
||||
import { ExperimentOverrides } from './ExperimentOverrides';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import Log from '../log';
|
||||
|
||||
export type ExperimentationScope = 'user' | 'account' | 'device';
|
||||
|
||||
export type ExperimentationConfigs = Record<ExperimentationScope, ExperimentationDefinition>;
|
||||
|
||||
/** Commands pass the account unit on each `getAccountNamespace` call. */
|
||||
export type ExperimentationUnits = Record<Exclude<ExperimentationScope, 'account'>, string | null>;
|
||||
|
||||
export const EMPTY_EXPERIMENTATION_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [],
|
||||
namespaces: [],
|
||||
};
|
||||
|
||||
const EMPTY_CONFIGS: ExperimentationConfigs = {
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
};
|
||||
|
||||
const NULL_UNITS: ExperimentationUnits = { user: null, device: null };
|
||||
|
||||
function sha1(input: string): string {
|
||||
return createHash('sha1').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export function createLoggerConfig(analytics: Analytics): LoggerConfig {
|
||||
return {
|
||||
flavorMap: new Map([
|
||||
['no-op', new NoOpExposureLogger()],
|
||||
['rudderstack', new AnalyticsExposureLogger(analytics)],
|
||||
]),
|
||||
fallbackLoggerType: 'no-op',
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OP_LOGGER_CONFIG: LoggerConfig = {
|
||||
flavorMap: new Map([['no-op', new NoOpExposureLogger()]]),
|
||||
fallbackLoggerType: 'no-op',
|
||||
};
|
||||
|
||||
/** Cached per (unit, name): the library deduplicates exposures per Namespace instance. */
|
||||
class ScopedNamespaces {
|
||||
private readonly factories: ReadonlyMap<string, (unit: ExperimentUnit) => Namespace>;
|
||||
private readonly instances = new Map<string, Map<string, Namespace>>();
|
||||
|
||||
constructor(
|
||||
definition: ExperimentationDefinition,
|
||||
loggerConfig: LoggerConfig,
|
||||
defaultUnit: string | null
|
||||
) {
|
||||
this.factories = parseExperimentationConfig(definition, { sha1Fn: sha1 }, loggerConfig);
|
||||
// Build every namespace now so an invalid server config throws here, in the fail-open path.
|
||||
if (defaultUnit) {
|
||||
for (const name of this.factories.keys()) {
|
||||
this.getNamespace(name, defaultUnit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getNamespace(name: string, unit: string): Namespace | null {
|
||||
const factory = this.factories.get(name);
|
||||
if (!factory) {
|
||||
return null;
|
||||
}
|
||||
let byName = this.instances.get(unit);
|
||||
if (!byName) {
|
||||
byName = new Map();
|
||||
this.instances.set(unit, byName);
|
||||
}
|
||||
let namespace = byName.get(name);
|
||||
if (!namespace) {
|
||||
namespace = factory([unit]);
|
||||
byName.set(name, namespace);
|
||||
}
|
||||
return namespace;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves experiment params for the current CLI run. Units: user is the actor ID, account is the
|
||||
* ID the command passes to `getAccountNamespace`, device is the analytics device ID.
|
||||
* User and account units are shared across surfaces. The device unit is specific to the CLI, so a
|
||||
* device namespace must not be shared with another surface.
|
||||
* The first `getParam` on a namespace logs an exposure, so call it only where the variant is used.
|
||||
*/
|
||||
export class ExperimentationClient {
|
||||
private readonly scopes: Record<ExperimentationScope, ScopedNamespaces>;
|
||||
|
||||
constructor(
|
||||
configs: ExperimentationConfigs,
|
||||
private readonly units: ExperimentationUnits,
|
||||
private readonly overrides: ExperimentOverrides,
|
||||
loggerConfig: LoggerConfig
|
||||
) {
|
||||
this.scopes = {
|
||||
user: new ScopedNamespaces(configs.user, loggerConfig, units.user),
|
||||
account: new ScopedNamespaces(configs.account, loggerConfig, null),
|
||||
device: new ScopedNamespaces(configs.device, loggerConfig, units.device),
|
||||
};
|
||||
}
|
||||
|
||||
/** Env overrides still apply, so QA can force a variant even with analytics disabled. */
|
||||
public static createDisabled(
|
||||
overrides: ExperimentOverrides = ExperimentOverrides.fromEnv()
|
||||
): ExperimentationClient {
|
||||
return new ExperimentationClient(EMPTY_CONFIGS, NULL_UNITS, overrides, NO_OP_LOGGER_CONFIG);
|
||||
}
|
||||
|
||||
public getUserNamespace(name: string): ExperimentNamespace {
|
||||
return this.resolve('user', name, this.units.user);
|
||||
}
|
||||
|
||||
/** Takes the account the command already resolved, so no extra lookup or config read happens here. */
|
||||
public getAccountNamespace(
|
||||
name: string,
|
||||
{ accountId }: { accountId: string }
|
||||
): ExperimentNamespace {
|
||||
return this.resolve('account', name, accountId);
|
||||
}
|
||||
|
||||
public getDeviceNamespace(name: string): ExperimentNamespace {
|
||||
return this.resolve('device', name, this.units.device);
|
||||
}
|
||||
|
||||
private resolve(
|
||||
scope: ExperimentationScope,
|
||||
name: string,
|
||||
unit: string | null
|
||||
): ExperimentNamespace {
|
||||
if (this.overrides.hasNamespaceOverride(name)) {
|
||||
return new OverrideFallbackNamespace(name, this.overrides);
|
||||
}
|
||||
if (!unit) {
|
||||
return FALLBACK_NAMESPACE;
|
||||
}
|
||||
|
||||
let namespace: Namespace | null;
|
||||
try {
|
||||
namespace = this.scopes[scope].getNamespace(name, unit);
|
||||
} catch (error) {
|
||||
Log.debug(
|
||||
`[experimentation] Failed to build namespace "${name}" in the ${scope} config. Returning defaults.`,
|
||||
error
|
||||
);
|
||||
return FALLBACK_NAMESPACE;
|
||||
}
|
||||
if (!namespace) {
|
||||
Log.debug(
|
||||
`[experimentation] Namespace "${name}" not found in the ${scope} config. Returning defaults.`
|
||||
);
|
||||
return FALLBACK_NAMESPACE;
|
||||
}
|
||||
return new SafeNamespace(namespace, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ExperimentationClient } from './ExperimentationClient';
|
||||
|
||||
/**
|
||||
* Defers building the experimentation client, and the config fetch behind it, until a command
|
||||
* first needs a variant.
|
||||
*/
|
||||
export class LazyExperimentationClient {
|
||||
private pending: Promise<ExperimentationClient> | null = null;
|
||||
|
||||
constructor(private readonly loadAsync: () => Promise<ExperimentationClient>) {}
|
||||
|
||||
public static fromClient(client: ExperimentationClient): LazyExperimentationClient {
|
||||
return new LazyExperimentationClient(async () => client);
|
||||
}
|
||||
|
||||
public async getClientAsync(): Promise<ExperimentationClient> {
|
||||
this.pending ??= this.loadAsync();
|
||||
return await this.pending;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
|
||||
import UserSettings from '../../user/UserSettings';
|
||||
import { createAnalyticsAsync } from '../../analytics/AnalyticsManager';
|
||||
import { AnalyticsExposureLogger } from '../AnalyticsExposureLogger';
|
||||
import { createMockAnalytics } from './fixtures';
|
||||
|
||||
const mockIdentify = jest.fn();
|
||||
const mockTrack = jest.fn();
|
||||
const mockFlush = jest.fn();
|
||||
|
||||
jest.mock('@expo/rudder-sdk-node', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
identify: mockIdentify,
|
||||
track: mockTrack,
|
||||
flush: mockFlush,
|
||||
}))
|
||||
);
|
||||
jest.mock('../../user/UserSettings', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteKeyAsync: jest.fn(),
|
||||
getAsync: jest.fn(),
|
||||
setAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../analytics/agent', () => ({ getAgentTelemetryContext: jest.fn() }));
|
||||
jest.mock('../../analytics/sandbox', () => ({ getSandboxTelemetryContext: jest.fn() }));
|
||||
|
||||
const originalDisableEasAnalytics = process.env.DISABLE_EAS_ANALYTICS;
|
||||
const originalHttpsProxy = process.env.https_proxy;
|
||||
|
||||
afterAll(() => {
|
||||
if (originalDisableEasAnalytics === undefined) {
|
||||
delete process.env.DISABLE_EAS_ANALYTICS;
|
||||
} else {
|
||||
process.env.DISABLE_EAS_ANALYTICS = originalDisableEasAnalytics;
|
||||
}
|
||||
if (originalHttpsProxy === undefined) {
|
||||
delete process.env.https_proxy;
|
||||
} else {
|
||||
process.env.https_proxy = originalHttpsProxy;
|
||||
}
|
||||
});
|
||||
|
||||
const exposure = {
|
||||
name: 'cli-test',
|
||||
time: 1700000000,
|
||||
salt: 'cli-full.cli-test.cli-test',
|
||||
};
|
||||
|
||||
describe(AnalyticsExposureLogger, () => {
|
||||
it('logs Experiment Viewed with experimentName, variationName, and unit', () => {
|
||||
const analytics = createMockAnalytics();
|
||||
new AnalyticsExposureLogger(analytics).logExposure({
|
||||
...exposure,
|
||||
unit: ['account-1'],
|
||||
params: { variant: 'treatment' },
|
||||
});
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: 'cli-test',
|
||||
variationName: 'treatment',
|
||||
unit: 'account-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('never sends an undefined variationName', () => {
|
||||
const analytics = createMockAnalytics();
|
||||
new AnalyticsExposureLogger(analytics).logExposure({
|
||||
...exposure,
|
||||
unit: ['account-1'],
|
||||
params: { enabled: true, count: 2 },
|
||||
});
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith(
|
||||
'Experiment Viewed',
|
||||
expect.objectContaining({ variationName: JSON.stringify({ enabled: true, count: 2 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('sends numeric units and variants as strings', () => {
|
||||
const analytics = createMockAnalytics();
|
||||
new AnalyticsExposureLogger(analytics).logExposure({
|
||||
...exposure,
|
||||
unit: [42],
|
||||
params: { variant: 3 },
|
||||
});
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith(
|
||||
'Experiment Viewed',
|
||||
expect.objectContaining({ unit: '42', variationName: '3' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reaches RudderStack with the CLI source and anonymous ID', async () => {
|
||||
jest.mocked(RudderAnalytics).mockClear();
|
||||
mockTrack.mockClear();
|
||||
jest
|
||||
.mocked(UserSettings.getAsync)
|
||||
.mockImplementation(async (key, defaultValue) =>
|
||||
key === 'analyticsDeviceId' ? 'persistent-device-id' : defaultValue
|
||||
);
|
||||
delete process.env.DISABLE_EAS_ANALYTICS;
|
||||
delete process.env.https_proxy;
|
||||
|
||||
const analytics = await createAnalyticsAsync();
|
||||
new AnalyticsExposureLogger(analytics).logExposure({
|
||||
...exposure,
|
||||
unit: ['account-1'],
|
||||
params: { variant: 'treatment' },
|
||||
});
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'Experiment Viewed',
|
||||
anonymousId: 'persistent-device-id',
|
||||
properties: expect.objectContaining({
|
||||
experimentName: 'cli-test',
|
||||
variationName: 'treatment',
|
||||
unit: 'account-1',
|
||||
source: 'eas cli',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Namespace } from '@expo/experimentation';
|
||||
|
||||
import Log from '../../log';
|
||||
import {
|
||||
FALLBACK_NAMESPACE,
|
||||
OverrideFallbackNamespace,
|
||||
SafeNamespace,
|
||||
coerceOverrideValue,
|
||||
} from '../ExperimentNamespace';
|
||||
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
describe(coerceOverrideValue, () => {
|
||||
it('coerces booleans only from "true" and "false"', () => {
|
||||
expect(coerceOverrideValue('false', true)).toBe(false);
|
||||
expect(coerceOverrideValue('TRUE', false)).toBe(true);
|
||||
expect(coerceOverrideValue('1', false)).toBe(false);
|
||||
expect(coerceOverrideValue('yes', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces finite numbers and falls back otherwise', () => {
|
||||
expect(coerceOverrideValue('2', 1)).toBe(2);
|
||||
expect(coerceOverrideValue('2.5', 1)).toBe(2.5);
|
||||
expect(coerceOverrideValue('x', 1)).toBe(1);
|
||||
expect(coerceOverrideValue('Infinity', 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('passes strings through and returns the default for empty input', () => {
|
||||
expect(coerceOverrideValue('b', 'a')).toBe('b');
|
||||
expect(coerceOverrideValue('', 'a')).toBe('a');
|
||||
expect(coerceOverrideValue(' ', 'a')).toBe('a');
|
||||
expect(coerceOverrideValue(undefined, 'a')).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FALLBACK_NAMESPACE', () => {
|
||||
it('returns the default for every param', () => {
|
||||
expect(FALLBACK_NAMESPACE.getParam('variant', 'control')).toBe('control');
|
||||
expect(FALLBACK_NAMESPACE.isOverridden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(OverrideFallbackNamespace, () => {
|
||||
const overrides = new ExperimentOverrides(parseExperimentOverrides('ns:variant=treatment'));
|
||||
const namespace = new OverrideFallbackNamespace('ns', overrides);
|
||||
|
||||
it('returns the coerced override for overridden params', () => {
|
||||
expect(namespace.getParam('variant', 'control')).toBe('treatment');
|
||||
expect(namespace.isOverridden).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the default for params without an override', () => {
|
||||
expect(namespace.getParam('enabled', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SafeNamespace, () => {
|
||||
it('delegates to the wrapped namespace', () => {
|
||||
const inner = { getParam: jest.fn().mockReturnValue('treatment') } as unknown as Namespace;
|
||||
expect(new SafeNamespace(inner, 'ns').getParam('variant', 'control')).toBe('treatment');
|
||||
expect(inner.getParam).toHaveBeenCalledWith('variant', 'control');
|
||||
});
|
||||
|
||||
it('returns the default and logs at debug level when the wrapped namespace throws', () => {
|
||||
const inner = {
|
||||
getParam: jest.fn(() => {
|
||||
throw new Error('bad operator');
|
||||
}),
|
||||
} as unknown as Namespace;
|
||||
expect(new SafeNamespace(inner, 'ns').getParam('variant', 'control')).toBe('control');
|
||||
expect(Log.debug).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
|
||||
|
||||
jest.mock('../../env', () => ({
|
||||
__esModule: true,
|
||||
default: { experimentOverride: 'from-env:variant=treatment' },
|
||||
}));
|
||||
|
||||
describe(parseExperimentOverrides, () => {
|
||||
it('parses a single override', () => {
|
||||
const map = parseExperimentOverrides('pricing:variant=treatment');
|
||||
expect(map.get('pricing')?.get('variant')).toBe('treatment');
|
||||
});
|
||||
|
||||
it('parses multiple overrides and multiple params per namespace', () => {
|
||||
const map = parseExperimentOverrides('a:variant=x,b:limit=3,a:enabled=true');
|
||||
expect(map.get('a')?.get('variant')).toBe('x');
|
||||
expect(map.get('a')?.get('enabled')).toBe('true');
|
||||
expect(map.get('b')?.get('limit')).toBe('3');
|
||||
});
|
||||
|
||||
it('returns an empty map for undefined or empty input', () => {
|
||||
expect(parseExperimentOverrides(undefined).size).toBe(0);
|
||||
expect(parseExperimentOverrides('').size).toBe(0);
|
||||
expect(parseExperimentOverrides(' , ').size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips entries without "=" or ":" and entries with an empty namespace or param', () => {
|
||||
const map = parseExperimentOverrides(
|
||||
'bogus,ns:x,variant=1,:variant=1,ns:=1,ok:variant=control'
|
||||
);
|
||||
expect([...map.keys()]).toEqual(['ok']);
|
||||
});
|
||||
|
||||
it('keeps "=" and ":" inside values', () => {
|
||||
const map = parseExperimentOverrides('ns:url=https://expo.dev/a=b');
|
||||
expect(map.get('ns')?.get('url')).toBe('https://expo.dev/a=b');
|
||||
});
|
||||
|
||||
it('trims whitespace and tolerates a trailing comma', () => {
|
||||
const map = parseExperimentOverrides(' ns : variant = treatment , ');
|
||||
expect(map.get('ns')?.get('variant')).toBe('treatment');
|
||||
expect(map.size).toBe(1);
|
||||
});
|
||||
|
||||
it('lets later duplicates win', () => {
|
||||
const map = parseExperimentOverrides('ns:variant=a,ns:variant=b');
|
||||
expect(map.get('ns')?.get('variant')).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe(ExperimentOverrides, () => {
|
||||
it('exposes namespace and param lookups', () => {
|
||||
const overrides = new ExperimentOverrides(parseExperimentOverrides('ns:variant=treatment'));
|
||||
expect(overrides.hasNamespaceOverride('ns')).toBe(true);
|
||||
expect(overrides.hasNamespaceOverride('other')).toBe(false);
|
||||
expect(overrides.hasOverride('ns', 'variant')).toBe(true);
|
||||
expect(overrides.hasOverride('ns', 'enabled')).toBe(false);
|
||||
expect(overrides.hasOverride('other', 'variant')).toBe(false);
|
||||
expect(overrides.getOverride('ns', 'variant')).toBe('treatment');
|
||||
expect(overrides.getOverride('ns', 'enabled')).toBeUndefined();
|
||||
expect(overrides.getOverride('other', 'variant')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads EAS_EXPERIMENT_OVERRIDE through env', () => {
|
||||
const overrides = ExperimentOverrides.fromEnv();
|
||||
expect(overrides.getOverride('from-env', 'variant')).toBe('treatment');
|
||||
});
|
||||
|
||||
it('empty() has no overrides', () => {
|
||||
expect(ExperimentOverrides.empty().hasNamespaceOverride('from-env')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
ExperimentationConfigs,
|
||||
ExperimentationUnits,
|
||||
createLoggerConfig,
|
||||
} from '../ExperimentationClient';
|
||||
import { ExperimentOverrides, parseExperimentOverrides } from '../ExperimentOverrides';
|
||||
import { TEST_DEFINITION, createMockAnalytics, findUnitsByValue } from './fixtures';
|
||||
import { FALLBACK_NAMESPACE } from '../ExperimentNamespace';
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../env', () => ({
|
||||
__esModule: true,
|
||||
default: { experimentOverride: 'cli-full:variant=from-env' },
|
||||
}));
|
||||
|
||||
function createClient({
|
||||
configs = {},
|
||||
units = {},
|
||||
overrides = ExperimentOverrides.empty(),
|
||||
analytics = createMockAnalytics(),
|
||||
}: {
|
||||
configs?: Partial<ExperimentationConfigs>;
|
||||
units?: Partial<ExperimentationUnits>;
|
||||
overrides?: ExperimentOverrides;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
} = {}): { client: ExperimentationClient; analytics: ReturnType<typeof createMockAnalytics> } {
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: configs.user ?? EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: configs.account ?? EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
device: configs.device ?? EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: units.user ?? null, device: units.device ?? null },
|
||||
overrides,
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
function userClientForUnit(unit: string): ExperimentationClient {
|
||||
return createClient({ configs: { user: TEST_DEFINITION }, units: { user: unit } }).client;
|
||||
}
|
||||
|
||||
const unitsByVariant = findUnitsByValue(
|
||||
unit => userClientForUnit(unit).getUserNamespace('cli-full'),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
const controlUnit = unitsByVariant.get('control')!;
|
||||
const treatmentUnit = unitsByVariant.get('treatment')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe(findUnitsByValue, () => {
|
||||
it('throws when no unit produces an expected value', () => {
|
||||
expect(() => findUnitsByValue(() => FALLBACK_NAMESPACE, 'variant', 'x', ['never'])).toThrow(
|
||||
'Could not find units for all of: never'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ExperimentationClient, () => {
|
||||
describe('assignment', () => {
|
||||
it('gives the same unit the same variant across clients', () => {
|
||||
const first = userClientForUnit(treatmentUnit).getUserNamespace('cli-full');
|
||||
const second = userClientForUnit(treatmentUnit).getUserNamespace('cli-full');
|
||||
expect(first.getParam('variant', 'control')).toBe('treatment');
|
||||
expect(second.getParam('variant', 'control')).toBe('treatment');
|
||||
expect(
|
||||
userClientForUnit(controlUnit).getUserNamespace('cli-full').getParam('variant', 'x')
|
||||
).toBe('control');
|
||||
});
|
||||
|
||||
it('returns the default and logs at debug level for an unknown namespace', () => {
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: treatmentUnit },
|
||||
});
|
||||
expect(client.getUserNamespace('missing').getParam('variant', 'control')).toBe('control');
|
||||
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('"missing"'));
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the default and logs no exposure when the scope has no unit', () => {
|
||||
const { client, analytics } = createClient({ configs: { user: TEST_DEFINITION } });
|
||||
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(Log.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an empty string unit like a missing unit', () => {
|
||||
const { client, analytics } = createClient({ configs: { account: TEST_DEFINITION } });
|
||||
expect(
|
||||
client.getAccountNamespace('cli-full', { accountId: '' }).getParam('variant', 'control')
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('buckets account experiments on the account ID the command passes', () => {
|
||||
const { client, analytics } = createClient({ configs: { account: TEST_DEFINITION } });
|
||||
expect(
|
||||
client.getAccountNamespace('cli-full', { accountId: controlUnit }).getParam('variant', 'x')
|
||||
).toBe('control');
|
||||
expect(
|
||||
client
|
||||
.getAccountNamespace('cli-full', { accountId: treatmentUnit })
|
||||
.getParam('variant', 'x')
|
||||
).toBe('treatment');
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves the same namespace name independently per scope', () => {
|
||||
const scoped = (choice: string): ExperimentationDefinition => ({
|
||||
experiments: [
|
||||
{
|
||||
experimentName: 'shared',
|
||||
paramDefinitions: { variant: { type: 'uniform_choice', choices: [choice] } },
|
||||
exposureLoggerType: 'no-op',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: 'shared',
|
||||
numSegments: 10,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'all', experimentName: 'shared', numSegments: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { client } = createClient({
|
||||
configs: {
|
||||
user: scoped('from-user'),
|
||||
account: scoped('from-account'),
|
||||
device: scoped('from-device'),
|
||||
},
|
||||
units: { user: 'u', device: 'd' },
|
||||
});
|
||||
expect(client.getUserNamespace('shared').getParam('variant', 'x')).toBe('from-user');
|
||||
expect(
|
||||
client.getAccountNamespace('shared', { accountId: 'a' }).getParam('variant', 'x')
|
||||
).toBe('from-account');
|
||||
expect(client.getDeviceNamespace('shared').getParam('variant', 'x')).toBe('from-device');
|
||||
});
|
||||
|
||||
it('returns the default and logs no exposure for a unit outside the allocated segments', () => {
|
||||
const unallocated = findUnitsByValue(
|
||||
unit => userClientForUnit(unit).getUserNamespace('cli-half'),
|
||||
'variant',
|
||||
'not-allocated',
|
||||
['not-allocated']
|
||||
).get('not-allocated')!;
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: unallocated },
|
||||
});
|
||||
expect(client.getUserNamespace('cli-half').getParam('variant', 'control')).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exposure logging', () => {
|
||||
it('logs one exposure per namespace per client', () => {
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: treatmentUnit },
|
||||
});
|
||||
const namespace = client.getUserNamespace('cli-full');
|
||||
namespace.getParam('variant', 'control');
|
||||
namespace.getParam('enabled', false);
|
||||
client.getUserNamespace('cli-full').getParam('variant', 'control');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: 'cli-test',
|
||||
variationName: 'treatment',
|
||||
unit: treatmentUnit,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs again for a second client with the same unit, so clients must be cached per command', () => {
|
||||
const analytics = createMockAnalytics();
|
||||
createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: treatmentUnit },
|
||||
analytics,
|
||||
})
|
||||
.client.getUserNamespace('cli-full')
|
||||
.getParam('variant', 'control');
|
||||
createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: treatmentUnit },
|
||||
analytics,
|
||||
})
|
||||
.client.getUserNamespace('cli-full')
|
||||
.getParam('variant', 'control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('logs nothing for experiments that use the no-op logger', () => {
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: 'unit-1' },
|
||||
});
|
||||
expect(['a', 'b']).toContain(
|
||||
client.getUserNamespace('cli-quiet-ns').getParam('variant', 'x')
|
||||
);
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the no-op logger for an unknown exposureLoggerType', () => {
|
||||
const definition: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: 'odd',
|
||||
paramDefinitions: { variant: { type: 'uniform_choice', choices: ['only'] } },
|
||||
exposureLoggerType: 'something-else',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: 'odd',
|
||||
numSegments: 10,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'all', experimentName: 'odd', numSegments: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: definition },
|
||||
units: { user: 'u' },
|
||||
});
|
||||
expect(client.getUserNamespace('odd').getParam('variant', 'x')).toBe('only');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('overrides', () => {
|
||||
it('returns the override instead of the assigned variant and logs no exposure', () => {
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: controlUnit },
|
||||
overrides: new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment')),
|
||||
});
|
||||
const namespace = client.getUserNamespace('cli-full');
|
||||
expect(namespace.isOverridden).toBe(true);
|
||||
expect(namespace.getParam('variant', 'control')).toBe('treatment');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the default for other params of an overridden namespace without logging an exposure', () => {
|
||||
const { client, analytics } = createClient({
|
||||
configs: { user: TEST_DEFINITION },
|
||||
units: { user: controlUnit },
|
||||
overrides: new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment')),
|
||||
});
|
||||
expect(client.getUserNamespace('cli-full').getParam('enabled', false)).toBe(false);
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies overrides to namespaces that are not in the server config', () => {
|
||||
const { client } = createClient({
|
||||
overrides: new ExperimentOverrides(
|
||||
parseExperimentOverrides('not-deployed:variant=treatment')
|
||||
),
|
||||
});
|
||||
expect(client.getUserNamespace('not-deployed').getParam('variant', 'control')).toBe(
|
||||
'treatment'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the same override in every scope', () => {
|
||||
const { client } = createClient({
|
||||
overrides: new ExperimentOverrides(parseExperimentOverrides('ns:enabled=true')),
|
||||
});
|
||||
expect(client.getUserNamespace('ns').getParam('enabled', false)).toBe(true);
|
||||
expect(client.getAccountNamespace('ns', { accountId: 'a' }).getParam('enabled', false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(client.getDeviceNamespace('ns').getParam('enabled', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid server config', () => {
|
||||
it('returns the default without throwing when a param uses an unknown operator', () => {
|
||||
const definition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: 'broken',
|
||||
paramDefinitions: { variant: { type: 'not-an-operator' } },
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: 'broken',
|
||||
numSegments: 10,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'all', experimentName: 'broken', numSegments: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as ExperimentationDefinition;
|
||||
const { client } = createClient({ configs: { user: definition }, units: { user: 'u' } });
|
||||
expect(client.getUserNamespace('broken').getParam('variant', 'control')).toBe('control');
|
||||
expect(Log.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const overAllocated: ExperimentationDefinition = {
|
||||
experiments: TEST_DEFINITION.experiments,
|
||||
namespaces: [
|
||||
{
|
||||
name: 'too-many',
|
||||
numSegments: 10,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'all', experimentName: 'cli-test', numSegments: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('throws during construction when a namespace over-allocates segments', () => {
|
||||
expect(() =>
|
||||
createClient({ configs: { user: overAllocated }, units: { user: 'u' } })
|
||||
).toThrow(/requested 20 segments/);
|
||||
});
|
||||
|
||||
it('returns the default when a namespace fails to build for an account ID', () => {
|
||||
// Account namespaces have no default unit, so the failure surfaces on lookup, not construction.
|
||||
const { client, analytics } = createClient({ configs: { account: overAllocated } });
|
||||
expect(
|
||||
client.getAccountNamespace('too-many', { accountId: 'a' }).getParam('variant', 'control')
|
||||
).toBe('control');
|
||||
expect(Log.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to build namespace'),
|
||||
expect.anything()
|
||||
);
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDisabled', () => {
|
||||
it('returns defaults for every scope and logs no exposure', () => {
|
||||
const client = ExperimentationClient.createDisabled(ExperimentOverrides.empty());
|
||||
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('control');
|
||||
expect(client.getDeviceNamespace('cli-full').getParam('variant', 'control')).toBe('control');
|
||||
expect(Log.debug).not.toHaveBeenCalled();
|
||||
// The account scope always has a unit, so the empty config reports the namespace as missing.
|
||||
expect(
|
||||
client.getAccountNamespace('cli-full', { accountId: 'a' }).getParam('variant', 'control')
|
||||
).toBe('control');
|
||||
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('reads overrides from the environment by default', () => {
|
||||
const client = ExperimentationClient.createDisabled();
|
||||
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('from-env');
|
||||
});
|
||||
|
||||
it('still honors overrides', () => {
|
||||
const client = ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(parseExperimentOverrides('cli-full:variant=treatment'))
|
||||
);
|
||||
expect(client.getUserNamespace('cli-full').getParam('variant', 'control')).toBe('treatment');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { ExperimentNamespace } from '../ExperimentNamespace';
|
||||
|
||||
/**
|
||||
* - `cli-full`: every unit is in the `cli-test` experiment (50% control, 50% treatment).
|
||||
* - `cli-half`: half of the units are in `cli-test`, the rest get defaults.
|
||||
* - `cli-quiet-ns`: experiment with the `no-op` exposure logger.
|
||||
*/
|
||||
export const TEST_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: 'cli-test',
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
enabled: { type: 'bernoulli', p: 0.5 },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
{
|
||||
experimentName: 'cli-quiet',
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['a', 'b'] },
|
||||
},
|
||||
exposureLoggerType: 'no-op',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: 'cli-full',
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'cli-test full', experimentName: 'cli-test', numSegments: 1000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cli-half',
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'cli-test half', experimentName: 'cli-test', numSegments: 500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cli-quiet-ns',
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{ method: 'ADD', name: 'cli-quiet full', experimentName: 'cli-quiet', numSegments: 1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function createMockAnalytics(): Analytics & { logEvent: jest.Mock } {
|
||||
return { logEvent: jest.fn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one unit per expected value by probing the real library, instead of hard-coding hashes.
|
||||
*/
|
||||
export function findUnitsByValue<T>(
|
||||
getNamespaceForUnit: (unit: string) => ExperimentNamespace,
|
||||
paramName: string,
|
||||
defaultValue: T,
|
||||
expectedValues: T[]
|
||||
): Map<T, string> {
|
||||
const found = new Map<T, string>();
|
||||
for (let i = 0; i < 1000 && found.size < expectedValues.length; i++) {
|
||||
const unit = `unit-${i}`;
|
||||
const value = getNamespaceForUnit(unit).getParam(paramName, defaultValue);
|
||||
if (expectedValues.includes(value) && !found.has(value)) {
|
||||
found.set(value, unit);
|
||||
}
|
||||
}
|
||||
if (found.size !== expectedValues.length) {
|
||||
throw new Error(`Could not find units for all of: ${expectedValues.join(', ')}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
+5
@@ -16113,6 +16113,11 @@ export type EnvironmentVariablesSharedWithSensitiveQueryVariables = Exact<{
|
||||
|
||||
export type EnvironmentVariablesSharedWithSensitiveQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null, environments?: Array<any> | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility: EnvironmentVariableVisibility, type: EnvironmentSecretType, valueWithFileContent?: string | null }> } } } };
|
||||
|
||||
export type ExperimentationConfigsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ExperimentationConfigsQuery = { __typename?: 'RootQuery', experimentation: { __typename?: 'ExperimentationQuery', userConfig: any, accountConfig: any, deviceConfig: any } };
|
||||
|
||||
export type FingerprintsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { OperationResult } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EMPTY_EXPERIMENTATION_DEFINITION } from '../../experimentation/ExperimentationClient';
|
||||
import { ExperimentationConfigsQuery, ExperimentationConfigsQueryVariables } from '../generated';
|
||||
|
||||
export type ExperimentationConfigsResult = {
|
||||
userConfig: ExperimentationDefinition;
|
||||
accountConfig: ExperimentationDefinition;
|
||||
deviceConfig: ExperimentationDefinition;
|
||||
};
|
||||
|
||||
/** Like `withErrorHandlingAsync`, but never logs. Callers fail open. */
|
||||
async function quietQueryAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
|
||||
const { data, error } = await promise;
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error('Returned query result data is null!');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The server sends an untyped JSONObject. Anything but the expected shape means no experiments. */
|
||||
function toExperimentationDefinition(value: unknown): ExperimentationDefinition {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
Array.isArray((value as { experiments?: unknown }).experiments) &&
|
||||
Array.isArray((value as { namespaces?: unknown }).namespaces)
|
||||
) {
|
||||
return value as ExperimentationDefinition;
|
||||
}
|
||||
return EMPTY_EXPERIMENTATION_DEFINITION;
|
||||
}
|
||||
|
||||
export const ExperimentationConfigQuery = {
|
||||
async getConfigsAsync(graphqlClient: ExpoGraphqlClient): Promise<ExperimentationConfigsResult> {
|
||||
const data = await quietQueryAsync(
|
||||
graphqlClient
|
||||
.query<ExperimentationConfigsQuery, ExperimentationConfigsQueryVariables>(
|
||||
gql`
|
||||
query ExperimentationConfigs {
|
||||
experimentation {
|
||||
userConfig
|
||||
accountConfig
|
||||
deviceConfig
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
// A failing network must not delay the command.
|
||||
{ additionalTypenames: ['ExperimentationQuery'], noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return {
|
||||
userConfig: toExperimentationDefinition(data.experimentation.userConfig),
|
||||
accountConfig: toExperimentationDefinition(data.experimentation.accountConfig),
|
||||
deviceConfig: toExperimentationDefinition(data.experimentation.deviceConfig),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,10 @@ import { AccountFragmentNode } from '../types/Account';
|
||||
import { MeActorFragmentNode } from '../types/Actor';
|
||||
|
||||
export const UserQuery = {
|
||||
async currentUserAsync(graphqlClient: ExpoGraphqlClient): Promise<CurrentUserQuery['meActor']> {
|
||||
async currentUserAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ noRetry = false }: { noRetry?: boolean } = {}
|
||||
): Promise<CurrentUserQuery['meActor']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<CurrentUserQuery>(
|
||||
@@ -29,6 +32,7 @@ export const UserQuery = {
|
||||
{},
|
||||
{
|
||||
additionalTypenames: ['User', 'SSOUser'],
|
||||
noRetry,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import Log from '../../../log';
|
||||
import { ExperimentationConfigQuery } from '../ExperimentationConfigQuery';
|
||||
|
||||
jest.mock('../../../log');
|
||||
|
||||
const definition = {
|
||||
experiments: [{ experimentName: 'x', paramDefinitions: {}, exposureLoggerType: 'rudderstack' }],
|
||||
namespaces: [],
|
||||
};
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
query: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
|
||||
}
|
||||
|
||||
describe('ExperimentationConfigQuery.getConfigsAsync', () => {
|
||||
it('returns the three configs', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
experimentation: {
|
||||
userConfig: definition,
|
||||
accountConfig: definition,
|
||||
deviceConfig: definition,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).resolves.toEqual({
|
||||
userConfig: definition,
|
||||
accountConfig: definition,
|
||||
deviceConfig: definition,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces malformed configs with an empty definition', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
experimentation: {
|
||||
userConfig: { experiments: 'nope', namespaces: [] },
|
||||
accountConfig: null,
|
||||
deviceConfig: definition,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).resolves.toEqual({
|
||||
userConfig: { experiments: [], namespaces: [] },
|
||||
accountConfig: { experiments: [], namespaces: [] },
|
||||
deviceConfig: definition,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables retries and selects only the config fields', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: { experimentation: { userConfig: {}, accountConfig: {}, deviceConfig: {} } },
|
||||
});
|
||||
await ExperimentationConfigQuery.getConfigsAsync(graphqlClient);
|
||||
|
||||
const [document, variables, context] = graphqlClient.query.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('experimentation');
|
||||
expect(printed).toContain('userConfig');
|
||||
expect(printed).toContain('accountConfig');
|
||||
expect(printed).toContain('deviceConfig');
|
||||
expect(printed).not.toContain('deviceExperimentationUnit');
|
||||
expect(variables).toEqual({});
|
||||
expect(context).toEqual({ additionalTypenames: ['ExperimentationQuery'], noRetry: true });
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error without logging', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('offline') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).rejects.toBe(error);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when the result has no data', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: null });
|
||||
await expect(ExperimentationConfigQuery.getConfigsAsync(graphqlClient)).rejects.toThrow(
|
||||
'Returned query result data is null!'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import http from 'http';
|
||||
import { AddressInfo } from 'net';
|
||||
|
||||
import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserQuery } from '../UserQuery';
|
||||
|
||||
jest.mock('../../../api', () => ({
|
||||
getExpoApiBaseUrl: () => process.env.USER_QUERY_TEST_URL,
|
||||
}));
|
||||
|
||||
describe(UserQuery.currentUserAsync, () => {
|
||||
describe('against a slow server', () => {
|
||||
const server = http.createServer();
|
||||
const serverDelayMs = 2000;
|
||||
const requestTimeoutMs = 100;
|
||||
let requestCount = 0;
|
||||
// Aborted requests leave their response timers pending; clear them so the worker can exit.
|
||||
const responseTimers: NodeJS.Timeout[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server.on('request', (_req, res) => {
|
||||
requestCount += 1;
|
||||
responseTimers.push(
|
||||
setTimeout(() => {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ data: { meActor: null } }));
|
||||
}, serverDelayMs)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
process.env.USER_QUERY_TEST_URL = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
requestCount = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.USER_QUERY_TEST_URL;
|
||||
for (const timer of responseTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('fails after one timed-out request when retries are disabled', async () => {
|
||||
const client = createGraphqlClient(
|
||||
{ accessToken: 'token', sessionSecret: null },
|
||||
{ requestTimeoutMs }
|
||||
);
|
||||
await expect(UserQuery.currentUserAsync(client, { noRetry: true })).rejects.toThrow(
|
||||
`Request timed out after ${requestTimeoutMs} ms`
|
||||
);
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('retries a timed-out request by default', async () => {
|
||||
const client = createGraphqlClient(
|
||||
{ accessToken: 'token', sessionSecret: null },
|
||||
{ requestTimeoutMs }
|
||||
);
|
||||
await expect(UserQuery.currentUserAsync(client)).rejects.toThrow(
|
||||
`Request timed out after ${requestTimeoutMs} ms`
|
||||
);
|
||||
expect(requestCount).toBe(2);
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AddressInfo } from 'net';
|
||||
import nock from 'nock';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import {
|
||||
UnsupportedServeSimHostActionError,
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../serveSimHost';
|
||||
import { DeviceRunSessionRemoteConfig } from '../utils';
|
||||
|
||||
const TOKEN = 'preview-token';
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
|
||||
describe(getServeSimHost, () => {
|
||||
it('reads the web preview host of a controller session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('reads the preview host of a serve-sim session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
previewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('returns null before the session reports its preview host', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
previewApiUrl: null,
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getServeSimDeviceUdidAsync, () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('reads the device from the preview state with the session token', async () => {
|
||||
nock('https://preview.example.com', {
|
||||
reqheaders: { authorization: `Bearer ${TOKEN}` },
|
||||
})
|
||||
.get('/api')
|
||||
.reply(200, { device: UDID, port: 3200 });
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).resolves.toBe(UDID);
|
||||
});
|
||||
|
||||
it('fails when no simulator is running', async () => {
|
||||
nock('https://preview.example.com').get('/api').reply(200, 'null');
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).rejects.toThrow('no running simulator');
|
||||
});
|
||||
});
|
||||
|
||||
describe(runServeSimHostActionAsync, () => {
|
||||
let server: WebSocketServer;
|
||||
let apiUrl: string;
|
||||
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
|
||||
let protocols: string[];
|
||||
let requests: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
protocols = [];
|
||||
requests = [];
|
||||
server = new WebSocketServer({
|
||||
port: 0,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: offered => {
|
||||
protocols = [...offered];
|
||||
return offered.values().next().value ?? false;
|
||||
},
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', data => {
|
||||
const request = JSON.parse(data.toString());
|
||||
requests.push(request);
|
||||
socket.send(JSON.stringify(reply(request)));
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
server.once('listening', resolve);
|
||||
});
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs one action over the token-authenticated exec channel', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).resolves.toEqual({ stdout: '', stderr: '' });
|
||||
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
|
||||
expect(requests).toEqual([
|
||||
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a rejected session token', async () => {
|
||||
server.removeAllListeners('connection');
|
||||
server.on('connection', socket => {
|
||||
socket.close();
|
||||
});
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('rejected the session token');
|
||||
});
|
||||
|
||||
it('reports an action the session does not have yet', async () => {
|
||||
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
|
||||
).rejects.toThrow(UnsupportedServeSimHostActionError);
|
||||
});
|
||||
|
||||
it('reports a failed command with its stderr', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('appearance.set failed: Invalid device');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { resolveSimctlHostAction } from '../simctl';
|
||||
|
||||
describe(resolveSimctlHostAction, () => {
|
||||
it('maps the appearance command', () => {
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'dark' },
|
||||
});
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'light' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps the SpringBoard restart', () => {
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
])
|
||||
).toEqual({ action: 'springboard.restart', params: {} });
|
||||
});
|
||||
|
||||
it('rejects every other command', () => {
|
||||
expect(resolveSimctlHostAction([])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.backboardd',
|
||||
])
|
||||
).toBeNull();
|
||||
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import fetch, { httpsProxyAgent } from '../fetch';
|
||||
import { DeviceRunSessionRemoteConfig } from './utils';
|
||||
|
||||
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type ServeSimHost = {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ServeSimHostActionResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export class UnsupportedServeSimHostActionError extends Error {}
|
||||
|
||||
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.previewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
|
||||
: null;
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
case 'AppiumRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
|
||||
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${host.token}` },
|
||||
});
|
||||
const state = (await response.json()) as { device?: unknown } | null;
|
||||
if (typeof state?.device !== 'string' || state.device.length === 0) {
|
||||
throw new Error('The simulator session has no running simulator.');
|
||||
}
|
||||
return state.device;
|
||||
}
|
||||
|
||||
export async function runServeSimHostActionAsync(
|
||||
host: ServeSimHost,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
): Promise<ServeSimHostActionResult> {
|
||||
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
|
||||
const socket = new WebSocket(
|
||||
url.toString(),
|
||||
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
|
||||
{
|
||||
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
|
||||
}
|
||||
);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.terminate();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result!);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
|
||||
}, timeoutMs);
|
||||
socket.on('error', error => {
|
||||
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
|
||||
});
|
||||
socket.on('close', () => {
|
||||
finish(
|
||||
new Error(
|
||||
sent
|
||||
? `The simulator host closed the connection before ${action} finished.`
|
||||
: 'The simulator host rejected the session token.'
|
||||
)
|
||||
);
|
||||
});
|
||||
socket.on('message', data => {
|
||||
let reply: any;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
} catch {
|
||||
finish(new Error('The simulator host sent an invalid response.'));
|
||||
return;
|
||||
}
|
||||
if (reply?.ready === true && !sent) {
|
||||
sent = true;
|
||||
socket.send(JSON.stringify({ id: 1, action, params }));
|
||||
return;
|
||||
}
|
||||
if (reply?.id !== 1 || !sent) {
|
||||
return;
|
||||
}
|
||||
if (typeof reply.error === 'string') {
|
||||
finish(
|
||||
reply.error === `unknown action ${action}`
|
||||
? new UnsupportedServeSimHostActionError(
|
||||
`This simulator session does not support ${action} yet. Start a new session to use it.`
|
||||
)
|
||||
: new Error(`${action} failed: ${reply.error}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
|
||||
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
|
||||
if (reply.exitCode !== 0) {
|
||||
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
|
||||
return;
|
||||
}
|
||||
finish(null, { stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServeSimUrl(apiUrl: string, route: string): URL {
|
||||
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type SimctlHostAction = {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SPRINGBOARD_RESTART_ARGS = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
export const SUPPORTED_SIMCTL_COMMANDS = [
|
||||
'ui appearance <light|dark>',
|
||||
SPRINGBOARD_RESTART_ARGS.join(' '),
|
||||
];
|
||||
|
||||
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
|
||||
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
|
||||
const value = args[2];
|
||||
if (value === 'light' || value === 'dark') {
|
||||
return { action: 'appearance.set', params: { value } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
args.length === SPRINGBOARD_RESTART_ARGS.length &&
|
||||
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
|
||||
) {
|
||||
return { action: 'springboard.restart', params: {} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -87,13 +87,23 @@ export default class SessionManager {
|
||||
await this.setSessionAsync(undefined);
|
||||
}
|
||||
|
||||
public async getUserAsync(): Promise<Actor | undefined> {
|
||||
/**
|
||||
* @param requestTimeoutMs caps the actor lookup when no actor is cached yet. Callers that must
|
||||
* not delay a command past a deadline pass it; the default has no timeout. A capped lookup
|
||||
* also skips the retry exchange, since a retry would double the wait.
|
||||
*/
|
||||
public async getUserAsync({ requestTimeoutMs }: { requestTimeoutMs?: number } = {}): Promise<
|
||||
Actor | undefined
|
||||
> {
|
||||
if (!this.currentActor && (this.getAccessToken() || this.getSessionSecret())) {
|
||||
const authenticationInfo = {
|
||||
accessToken: this.getAccessToken(),
|
||||
sessionSecret: this.getSessionSecret(),
|
||||
};
|
||||
const actor = await UserQuery.currentUserAsync(createGraphqlClient(authenticationInfo));
|
||||
const actor = await UserQuery.currentUserAsync(
|
||||
createGraphqlClient(authenticationInfo, { requestTimeoutMs }),
|
||||
{ noRetry: requestTimeoutMs !== undefined }
|
||||
);
|
||||
this.currentActor = actor ?? undefined;
|
||||
if (actor) {
|
||||
this.analytics.setActor(actor);
|
||||
|
||||
@@ -5,6 +5,8 @@ import path from 'path';
|
||||
import { ApiV2Error } from '../../ApiV2Error';
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
import { ApiV2Client } from '../../api';
|
||||
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserQuery } from '../../graphql/queries/UserQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getStateJsonPath } from '../../utils/paths';
|
||||
@@ -17,16 +19,17 @@ jest.mock('../../log');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../graphql/queries/UserQuery', () => ({
|
||||
UserQuery: {
|
||||
currentUserAsync: async () => ({
|
||||
currentUserAsync: jest.fn(async () => ({
|
||||
__typename: 'User',
|
||||
username: 'USERNAME',
|
||||
id: 'USER_ID',
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
jest.mock('../fetchSessionSecretAndUser');
|
||||
jest.mock('../fetchSessionSecretAndUserFromBrowserAuthFlow');
|
||||
jest.mock('../../api');
|
||||
jest.mock('../../commandUtils/context/contextUtils/createGraphqlClient');
|
||||
|
||||
const authStub: any = {
|
||||
sessionSecret: 'SESSION_SECRET',
|
||||
@@ -41,6 +44,7 @@ const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn((): void => {}),
|
||||
setActor: jest.fn((): void => {}),
|
||||
flushAsync: jest.fn(async (): Promise<void> => {}),
|
||||
getDeviceId: jest.fn((): string | null => null),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -131,6 +135,36 @@ describe(SessionManager, () => {
|
||||
});
|
||||
expect(await sessionManager.getUserAsync()).toMatchObject({ __typename: 'User' });
|
||||
});
|
||||
|
||||
it('creates the GraphQL client without a timeout and keeps retries by default', async () => {
|
||||
process.env.EXPO_TOKEN = 'accesstoken';
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager.getUserAsync();
|
||||
expect(createGraphqlClient).toHaveBeenCalledWith(
|
||||
{ accessToken: 'accesstoken', sessionSecret: null },
|
||||
{ requestTimeoutMs: undefined }
|
||||
);
|
||||
expect(jest.mocked(UserQuery.currentUserAsync).mock.calls[0][1]).toEqual({ noRetry: false });
|
||||
});
|
||||
|
||||
it('forwards the request timeout to the GraphQL client and disables retries', async () => {
|
||||
process.env.EXPO_TOKEN = 'accesstoken';
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager.getUserAsync({ requestTimeoutMs: 1234 });
|
||||
expect(createGraphqlClient).toHaveBeenCalledWith(
|
||||
{ accessToken: 'accesstoken', sessionSecret: null },
|
||||
{ requestTimeoutMs: 1234 }
|
||||
);
|
||||
expect(jest.mocked(UserQuery.currentUserAsync).mock.calls[0][1]).toEqual({ noRetry: true });
|
||||
});
|
||||
|
||||
it('skips the request once an actor is cached', async () => {
|
||||
process.env.EXPO_TOKEN = 'accesstoken';
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager.getUserAsync();
|
||||
await sessionManager.getUserAsync({ requestTimeoutMs: 1234 });
|
||||
expect(createGraphqlClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginAsync', () => {
|
||||
|
||||
@@ -18,7 +18,12 @@ const now = Date.parse('2026-09-29T12:00:00Z');
|
||||
let directory: string;
|
||||
|
||||
function manager(): SessionManager {
|
||||
return new SessionManager({ logEvent: jest.fn(), setActor: jest.fn(), flushAsync: jest.fn() });
|
||||
return new SessionManager({
|
||||
logEvent: jest.fn(),
|
||||
setActor: jest.fn(),
|
||||
flushAsync: jest.fn(),
|
||||
getDeviceId: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function startAsync(): Promise<string> {
|
||||
|
||||
@@ -2854,6 +2854,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/experimentation@npm:0.0.5":
|
||||
version: 0.0.5
|
||||
resolution: "@expo/experimentation@npm:0.0.5"
|
||||
dependencies:
|
||||
nullthrows: "npm:^1.1.1"
|
||||
checksum: 10c0/bc054e1bcc0583f2a09e8b56d2f95bb62cf2f4eaf78edac4eb709c73cfd1fdb93626b948f4184f6dc8096a44b6c12c97109a84622bb56620484b4e61856007fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/image-utils@npm:^0.7.6":
|
||||
version: 0.7.6
|
||||
resolution: "@expo/image-utils@npm:0.7.6"
|
||||
@@ -10736,6 +10745,7 @@ __metadata:
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/eas-json": "npm:25.1.1"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
|
||||
Reference in new issue
Block a user