mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b8a78145 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 | ||
|
|
44b63e5a7d | ||
|
|
42e4fa8a7c | ||
|
|
e4bb181802 | ||
|
|
53ac936b11 | ||
|
|
c0d3f4f935 | ||
|
|
5ec3d84fcb | ||
|
|
d0cb5a2d67 | ||
|
|
aae7d13ac0 | ||
|
|
d9b1f866d4 | ||
|
|
a03dca45eb | ||
|
|
f949f3430e |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Apply the job log's secret masking to uploaded Xcode build logs.
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "25.2.0",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/template-file": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
+5
-3
@@ -79,9 +79,11 @@ beforeEach(() => {
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -201,6 +202,263 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments and URLs for the device shell', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity',
|
||||
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
|
||||
openUrl: 'exp://example.test/path?a=1&b=2',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-d',
|
||||
'euxy://tempo',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'--es',
|
||||
'quote',
|
||||
"'it'\\''s'",
|
||||
'-n',
|
||||
'dev.example.app/dev.example.app.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
"'exp://example.test/path?a=1&b=2'",
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes iOS launch arguments with spaces unquoted', async () => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
launchArgs: ['-name', 'hello world'],
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips quoted Android arguments through a POSIX shell', async () => {
|
||||
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
|
||||
const launchArgs = [
|
||||
'--es',
|
||||
'text',
|
||||
'hello world',
|
||||
'--es',
|
||||
'json',
|
||||
'{"a":1,"b":2}',
|
||||
'--es',
|
||||
'shell',
|
||||
'$(printf injected) `printf injected` $HOME ; & | < >',
|
||||
'--es',
|
||||
'glob',
|
||||
'*',
|
||||
'--es',
|
||||
'home',
|
||||
'~',
|
||||
'--esa',
|
||||
'list',
|
||||
'a\\,b,c',
|
||||
'--es',
|
||||
'whitespace',
|
||||
'one\ntwo\tthree',
|
||||
'--es',
|
||||
'unicode',
|
||||
'héllo 🌍',
|
||||
'--es',
|
||||
'empty',
|
||||
'',
|
||||
];
|
||||
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity$Inner',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
// adb joins the words after `shell` with spaces and the device shell parses the result.
|
||||
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
|
||||
const { stdout } = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return stdout.split('\0').slice(0, -1);
|
||||
});
|
||||
expect(parsedCommands).toEqual([
|
||||
['start', ...launchArgs, '-n', component],
|
||||
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
|
||||
'passes empty Android option values as empty arguments (%j)',
|
||||
async launchArgs => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('explains an Android launch failure when a launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true', ''],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
message: expect.stringContaining('empty argument at position 4'),
|
||||
cause: launchError,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toBe(launchError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ launchArgs: ['--es', 'key', 'a\0b'] },
|
||||
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
|
||||
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining([expect.stringContaining('\0')]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
|
||||
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android URL command of 65,001 bytes', async () => {
|
||||
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android launch command that is too long after quoting', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
@@ -237,6 +495,36 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--es', 'probe', 'hello world'],
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
|
||||
@@ -162,6 +162,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
|
||||
+19
-2
@@ -76,7 +76,8 @@ async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -88,7 +89,7 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
@@ -133,6 +134,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
@@ -202,6 +204,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
|
||||
application: Promise.resolve(),
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
|
||||
@@ -687,6 +687,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).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 });
|
||||
|
||||
+397
-7
@@ -6,14 +6,18 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
@@ -27,11 +31,16 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
IosSimulatorUtils: {
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -48,6 +57,15 @@ jest.mock('../../../utils/turtleFetch', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
@@ -88,7 +106,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
@@ -97,7 +114,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
udid: 'ios-simulator-id',
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
@@ -113,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
@@ -212,6 +227,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const stopEventCollection = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
})) as never);
|
||||
@@ -242,6 +264,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: jest.fn(),
|
||||
});
|
||||
});
|
||||
@@ -250,16 +273,22 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
|
||||
async function runSessionAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_idle_time_minutes: { value: maxIdleTimeMinutes },
|
||||
...startupInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
@@ -280,6 +309,345 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
...(bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}),
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
frame_ancestors: { value: ['https://preview.example.com'] },
|
||||
cors_origins: { value: ['https://api.example.com'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
frameAncestors: ['https://preview.example.com'],
|
||||
corsOrigins: ['https://api.example.com'],
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
let releaseInstall!: (value: unknown) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise(resolve => {
|
||||
releaseInstall = resolve;
|
||||
}) as never;
|
||||
});
|
||||
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
open_url: { value: 'example://home' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
openUrl: 'example://home',
|
||||
})
|
||||
);
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(readyHost.finishAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
|
||||
releaseInstall({ stdout: '' });
|
||||
await running;
|
||||
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
|
||||
});
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
|
||||
const hostError = new Error('Simulator boot failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
await installEntered;
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
hostOptions.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
|
||||
const hostError = new Error('Simulator startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
|
||||
);
|
||||
let installed!: () => void;
|
||||
const installationFinished = new Promise<void>(resolve => {
|
||||
installed = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'driver' && args[1] === 'install') {
|
||||
installed();
|
||||
}
|
||||
return { stdout: args.includes('--json') ? '{}' : '' };
|
||||
}) as never);
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
await installationFinished;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an installer failure before a later host failure during home cleanup', async () => {
|
||||
const installError = new Error('npm install failed');
|
||||
const hostError = new Error('later Simulator failure');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawn).mockRejectedValueOnce(installError);
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
let enteredCleanup!: () => void;
|
||||
const cleanupEntered = new Promise<void>(resolve => {
|
||||
enteredCleanup = resolve;
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
|
||||
enteredCleanup();
|
||||
await cleanupGate;
|
||||
await remove(directory, options);
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await cleanupEntered;
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.reason).toBe(installError);
|
||||
options.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await expect(running).rejects.toBe(installError);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['WDA download', 'Appium readiness'])(
|
||||
'keeps the Simulator error when it interrupts %s',
|
||||
async stage => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const hostError = new Error('Simulator launch failed');
|
||||
const laterError = new Error(`${stage} interrupted`);
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredStage!: () => void;
|
||||
const stageEntered = new Promise<void>(resolve => {
|
||||
enteredStage = resolve;
|
||||
});
|
||||
let rejectStage!: (error: Error) => void;
|
||||
let setupSignal: AbortSignal | undefined;
|
||||
const waitForStage = (signal?: AbortSignal): Promise<never> => {
|
||||
setupSignal = signal;
|
||||
enteredStage();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectStage = reject;
|
||||
signal?.addEventListener('abort', () => reject(laterError), { once: true });
|
||||
});
|
||||
};
|
||||
if (stage === 'WDA download') {
|
||||
jest
|
||||
.mocked(preparePreinstalledWdaAsync)
|
||||
.mockImplementationOnce(({ signal }) => waitForStage(signal));
|
||||
} else {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementationOnce((_url, _method, options) =>
|
||||
waitForStage(options?.signal ?? undefined)
|
||||
);
|
||||
}
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await stageEntered;
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(setupSignal?.aborted).toBe(true);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectStage?.(laterError);
|
||||
await observed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
@@ -307,10 +675,32 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('fails a finished session when Appium cannot be stopped', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: undefined })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
});
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
|
||||
+328
-2
@@ -7,8 +7,9 @@ import path from 'node:path';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
@@ -38,13 +40,32 @@ jest.mock('node:os', () => {
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
isProcessDescendantOfAsync: jest.fn(),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
getAvailableDevicesAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
@@ -104,6 +125,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -217,6 +239,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -507,6 +530,35 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
@@ -592,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
}) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
})
|
||||
);
|
||||
const error = new Error('Argent install failed');
|
||||
rejectInstall(error);
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
|
||||
'stops and drains the pending %s command when the concurrent host fails',
|
||||
async flag => {
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
const commandError = new Error('Argent command exited after cancellation');
|
||||
let failHost!: (error: Error) => void;
|
||||
let finishHostCleanup!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async (_ctx, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = error => {
|
||||
options.onStartupError?.(error);
|
||||
finishHostCleanup = () => reject(error);
|
||||
};
|
||||
})
|
||||
);
|
||||
let failCommand!: (error: Error) => void;
|
||||
let commandStarted!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
commandStarted = resolve;
|
||||
});
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
if (!args.includes(flag)) {
|
||||
return Promise.resolve(undefined) as never;
|
||||
}
|
||||
const pending = new Promise((_resolve, reject) => {
|
||||
failCommand = reject;
|
||||
});
|
||||
commandStarted();
|
||||
return Object.assign(pending, { child }) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
let settled = false;
|
||||
void running
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await commandEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
failCommand(commandError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishHostCleanup();
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failCommand?.(commandError);
|
||||
finishHostCleanup?.();
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('interrupts tool-server state polling and retains the original host error', async () => {
|
||||
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let pollingStarted!: () => void;
|
||||
const pollingEntered = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
const getExitError = jest.fn<Error | undefined, []>(() => {
|
||||
pollingStarted();
|
||||
return undefined;
|
||||
});
|
||||
const stopServer = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => 'later controller diagnostic',
|
||||
getExitError,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
void running.catch(() => {});
|
||||
try {
|
||||
await pollingEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.aborted).toBe(true);
|
||||
getExitError.mockReturnValue(new Error('later Argent exit'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getExitError.mockReturnValue(new Error('stop pending state poll'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as CustomBuildContext,
|
||||
{} as never
|
||||
);
|
||||
let releaseHost!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
releaseHost = () => resolve(host);
|
||||
})
|
||||
);
|
||||
let stateReady!: () => void;
|
||||
const stateRead = new Promise<void>(resolve => {
|
||||
stateReady = resolve;
|
||||
});
|
||||
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
|
||||
stateReady();
|
||||
return true;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
try {
|
||||
await stateRead;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseHost();
|
||||
await running;
|
||||
}
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
+5
@@ -81,6 +81,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -97,6 +98,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -124,6 +126,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -151,6 +154,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
|
||||
@@ -168,6 +172,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
|
||||
+11
-2
@@ -140,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const names = jest
|
||||
const uploads = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
.mock.calls.map(([, options]) => options);
|
||||
const names = uploads
|
||||
.filter(options => options.kind === 'screen-recording')
|
||||
.map(options => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
@@ -151,6 +154,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
|
||||
expect(metadata.map(options => options.name)).toEqual(
|
||||
expect.arrayContaining(names.map(name => `${name} metadata`))
|
||||
);
|
||||
expect(metadata).toHaveLength(3);
|
||||
expect(uploads).toHaveLength(6);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -106,24 +106,40 @@ export async function launchApplicationAsync({
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
try {
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
// An empty argument that is not an option value reaches `am start` as an empty package, and
|
||||
// `am start` then fails to resolve the Intent without saying why.
|
||||
const emptyArgumentIndex = launchArgs.indexOf('');
|
||||
if (emptyArgumentIndex === -1 || err instanceof UserError) {
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
|
||||
@@ -265,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -164,6 +164,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -21,17 +22,19 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
@@ -39,6 +42,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -51,7 +55,6 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -78,6 +81,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -102,14 +106,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -122,54 +133,125 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
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),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -190,22 +272,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -240,13 +307,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -259,14 +328,16 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess.stopAsync();
|
||||
await appiumProcess?.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -296,15 +367,12 @@ export type AppiumDevice = {
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
@@ -341,65 +409,135 @@ export async function installAppiumAsync({
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
signal?.throwIfAborted();
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
add.command,
|
||||
add.args,
|
||||
{
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
signal
|
||||
);
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'update', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'install', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpawnResult> {
|
||||
if (!signal) {
|
||||
return await spawn(command, args, options);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
const result = await task;
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -29,13 +32,14 @@ import {
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -48,7 +52,6 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -90,6 +93,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -128,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -147,10 +158,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
@@ -162,74 +169,124 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
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),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
@@ -239,6 +296,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -253,6 +311,10 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
@@ -287,22 +349,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -339,13 +386,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -377,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -441,6 +490,31 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await spawn(command, args, options);
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
signal.throwIfAborted();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): ArgentToolServerState {
|
||||
let json: unknown;
|
||||
try {
|
||||
@@ -463,13 +537,16 @@ export async function waitForArgentToolServerStateAsync({
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
signal,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
@@ -480,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
@@ -505,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
if (signal) {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
|
||||
+35
-6
@@ -1,7 +1,7 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -40,7 +40,14 @@ beforeEach(async () => {
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -156,10 +163,14 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
const uploaded = new Map<string, Buffer>();
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('end', () => {
|
||||
uploaded.set(request.url!, Buffer.concat(chunks));
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
@@ -171,10 +182,18 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
|
||||
for (const recording of recordings) {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const recordingId = path.basename(recording.directory);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
|
||||
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
|
||||
);
|
||||
}
|
||||
expect(creationTokens).toEqual(Array(6).fill('session-token'));
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
@@ -185,6 +204,16 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
const manifests = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording-metadata')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
|
||||
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
|
||||
'cut.mp4',
|
||||
'done.mp4',
|
||||
'failed.mp4',
|
||||
]);
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { vol } from 'memfs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const recording = {
|
||||
udid: 'simulator-id',
|
||||
deviceName: 'iPhone Duo',
|
||||
runtimeDisplayName: 'iOS 27.1',
|
||||
directory: '/recordings/recording-id',
|
||||
};
|
||||
const manifest = {
|
||||
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
|
||||
width: 1440,
|
||||
height: 1200,
|
||||
recording: 'recording.mp4',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
deviceStates: [
|
||||
{
|
||||
timeMs: 12.5,
|
||||
state: {
|
||||
width: 1200,
|
||||
height: 1440,
|
||||
orientation: 'landscape-left',
|
||||
screen: 'inner',
|
||||
hingeAngle: 90,
|
||||
physicalOrientation: 'portrait',
|
||||
tableMode: false,
|
||||
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
|
||||
futureField: { label: '서울 🌲' },
|
||||
},
|
||||
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
|
||||
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
|
||||
vol.fromJSON({
|
||||
[recording.directory + '/session.json']: contents.toString(),
|
||||
[recording.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.kind === 'screen-recording') {
|
||||
await writeFile(recording.directory + '/session.json', '{}');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording],
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const inputs = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, input]) => input);
|
||||
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
|
||||
expect(inputs[1]).toMatchObject({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: 'recording-id-metadata',
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: 'recording-id.mp4',
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: contents.length,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of inputs[1].openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
expect(Buffer.concat(chunks)).toEqual(contents);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
|
||||
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
|
||||
for (const item of [recording, other]) {
|
||||
vol.fromJSON({
|
||||
[item.directory + '/session.json']: JSON.stringify(manifest),
|
||||
[item.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
}
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.filename === 'recording-id.json') {
|
||||
throw new Error('Storage unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording, other],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'recording-id.mp4',
|
||||
'recording-id.json',
|
||||
'other-recording.mp4',
|
||||
'other-recording.json',
|
||||
])
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording metadata',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
});
|
||||
@@ -12,7 +12,9 @@ import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -44,6 +46,11 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
}));
|
||||
jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() }));
|
||||
jest.mock('../serveSimActions');
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
@@ -129,6 +136,7 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
@@ -452,6 +460,62 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-10-19T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startServeSimActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start polling preview activity when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockImplementation(async () => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
});
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
const host = await startHostAsync();
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
@@ -1319,6 +1383,35 @@ it('uses the existing step logger for startup and shutdown output', async () =>
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the active local-egress address to serve-sim without tunnel credentials', async () => {
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({
|
||||
port: 8899,
|
||||
url: 'https://tunnel.example',
|
||||
token: 'tunnel-secret',
|
||||
fingerprint: 'fingerprint',
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
networkCapture: true,
|
||||
});
|
||||
const spawned = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(spawned.args).toEqual(
|
||||
expect.arrayContaining(['--network-capture-proxy', 'http://127.0.0.1:8899'])
|
||||
);
|
||||
expect(spawned.args).toContain('--network-capture');
|
||||
expect(JSON.stringify(spawned)).not.toContain('tunnel-secret');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('does not read the iOS egress handoff for an Android host', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'waits for setup before launch or boot-only readiness (app: %s)',
|
||||
async hasApp => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
@@ -78,3 +79,37 @@ it('does not enable preinstalled startup when the download produces no runner',
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeDownloader!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeDownloader = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(outdir)).toBe(true);
|
||||
closeDownloader(new Error('download interrupted'));
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(fs.existsSync(outdir)).toBe(false);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeDownloader(error);
|
||||
await observed;
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -660,6 +661,14 @@ describe(createServeSimArgs, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a capture proxy as one argument, including when capture will be enabled later', () => {
|
||||
const proxy = 'http://user:p%40ss%[email protected]:8899';
|
||||
const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy });
|
||||
expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]);
|
||||
expect(args).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy');
|
||||
});
|
||||
|
||||
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
|
||||
const args = createServeSimArgs({ port: 4321, networkCapture: true });
|
||||
expect(args).toContain('--network-capture');
|
||||
@@ -1930,6 +1939,47 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('keeps the session alive while preview input keeps arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts idle time from the later of automation and preview activity', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
const previewInputAt = new Date(Date.now() + 30_000);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () =>
|
||||
Date.now() >= previewInputAt.getTime() ? previewInputAt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Idle from 30 s, so the stop comes at 90 s: 18 polls.
|
||||
expect(workerApiCallCount('session')).toBe(18);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
stopError: new Error('forbidden'),
|
||||
@@ -2135,6 +2185,105 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => {
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
let finishReplacement!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishReplacement = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const surviving = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
const settled = jest.fn();
|
||||
const observed = owner.then(settled, settled);
|
||||
const borrowersSettled = jest.fn();
|
||||
const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled);
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
closeInstaller(new Error('brew interrupted'));
|
||||
await expect(owner).rejects.toBe(error);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(borrowersSettled).not.toHaveBeenCalled();
|
||||
finishReplacement();
|
||||
await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
expect(options.logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(error);
|
||||
finishReplacement();
|
||||
await Promise.all([observed, observedBorrowers]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops an aborted shared waiter without cancelling the installer owner', async () => {
|
||||
let finishInstall!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Appium startup failed');
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync(options);
|
||||
let ownerFinished = false;
|
||||
const observedOwner = owner.then(() => {
|
||||
ownerFinished = true;
|
||||
});
|
||||
const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const observedWaiter = waiter.catch(() => {});
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
await expect(waiter).rejects.toBe(error);
|
||||
expect(ownerFinished).toBe(false);
|
||||
expect(killGroup).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
finishInstall();
|
||||
await expect(owner).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
} finally {
|
||||
finishInstall();
|
||||
await Promise.all([observedOwner, observedWaiter]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // apt-get update
|
||||
@@ -2172,6 +2321,30 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an ordinary shared FFmpeg install failure', async () => {
|
||||
let failInstall!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failInstall = reject;
|
||||
}) as never
|
||||
);
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const callers = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
failInstall(new Error('Homebrew unavailable'));
|
||||
await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(options.logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The caller runs this with `void` and the worker installs no unhandledRejection
|
||||
// handler, so a rejection here would crash the process. `spawn` is not async and
|
||||
// can throw synchronously, which `asyncResult` cannot catch.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(fetchLatestServeSimEventIdAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
|
||||
'serve-sim event log'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startServeSimActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export namespace GooglePlayUtils {
|
||||
signal?.throwIfAborted();
|
||||
return isUploadStillProcessing(error);
|
||||
},
|
||||
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
{ retries: 13, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
logger.warn(
|
||||
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
@@ -206,7 +206,7 @@ export namespace GooglePlayUtils {
|
||||
} catch (error) {
|
||||
if (isUploadStillProcessing(error)) {
|
||||
throw new SystemError(
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,12 +110,12 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it('stops waiting for bundle processing after six attempts', async () => {
|
||||
it('stops waiting for bundle processing after 14 attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(6)
|
||||
.times(14)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
const error = await commit().catch(error => error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
|
||||
|
||||
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -140,9 +141,8 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
@@ -186,6 +186,31 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
openStream: () => createReadStream(recordingPath),
|
||||
attempts: 3,
|
||||
});
|
||||
try {
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `${recordingId}-metadata`,
|
||||
name: `${displayName} metadata`,
|
||||
filename: `${recordingId}.json`,
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: `${recordingId}.mp4`,
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: manifest.length,
|
||||
openStream: () => Readable.from(manifest),
|
||||
attempts: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording metadata', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
@@ -136,6 +138,7 @@ export function createServeSimArgs({
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
networkCaptureProxy,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
@@ -149,6 +152,7 @@ export function createServeSimArgs({
|
||||
installAppPath?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
networkCaptureProxy?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
@@ -188,6 +192,7 @@ export function createServeSimArgs({
|
||||
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
|
||||
]
|
||||
: []),
|
||||
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -350,6 +355,8 @@ export type DeviceWebPreview = {
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
@@ -493,6 +500,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
@@ -522,6 +530,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
@@ -577,6 +588,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
@@ -634,6 +646,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
getLastPreviewActivityAt() {
|
||||
return previewActivity?.getLastActivityObservedAt();
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
@@ -642,6 +657,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
@@ -769,6 +785,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
|
||||
@@ -12,13 +12,16 @@ export async function preparePreinstalledWdaAsync({
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
signal?.throwIfAborted();
|
||||
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
|
||||
runtimeIdentifier ?? ''
|
||||
)?.[1];
|
||||
@@ -52,10 +55,17 @@ export async function preparePreinstalledWdaAsync({
|
||||
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
|
||||
// close event pending. Kill the owned group, then await close before cleanup.
|
||||
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
|
||||
const abort = (): void => killProcessGroup(download.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await download;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
|
||||
@@ -67,6 +77,7 @@ export async function preparePreinstalledWdaAsync({
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(outdir, { recursive: true, force: true });
|
||||
signal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessGroupRunning } from '../../utils/processes';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
@@ -126,6 +126,8 @@ export type DeviceRunSessionIdleTimeout = {
|
||||
* begins, so a session nobody ever connects to still times out.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
/** Last observed preview input, counted like a session event. */
|
||||
getLastPreviewActivityAt?: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -179,9 +181,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
for (const observedAt of [
|
||||
idleTimeout.getLastEventObservedAt(),
|
||||
idleTimeout.getLastPreviewActivityAt?.(),
|
||||
]) {
|
||||
if (observedAt && observedAt > lastActivityAt) {
|
||||
lastActivityAt = observedAt;
|
||||
}
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
@@ -311,14 +317,28 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
|
||||
async function installFfmpegWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
await spawn('brew', ['install', 'ffmpeg'], {
|
||||
signal?.throwIfAborted();
|
||||
const install = spawn('brew', ['install', 'ffmpeg'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
...(signal ? { detached: true } : {}),
|
||||
});
|
||||
const abort = (): void => killProcessGroup(install.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await install;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
async function installFfmpegWithAptAsync({
|
||||
@@ -336,7 +356,7 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
@@ -356,13 +376,16 @@ async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'aborted' | void> {
|
||||
try {
|
||||
const ffmpegPath = await findFfmpegOnPathAsync(env);
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegPath) {
|
||||
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
|
||||
warmUpFfmpeg(env);
|
||||
@@ -376,13 +399,17 @@ async function ensureFfmpegInstalledAsync({
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
await installFfmpegWithHomebrewAsync({ env, logger, signal });
|
||||
} else {
|
||||
await installFfmpegWithAptAsync({ env, logger });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Installed ffmpeg.');
|
||||
warmUpFfmpeg(env);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return 'aborted';
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
@@ -398,20 +425,46 @@ export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
const sharedSetup = ffmpegSetupPromise;
|
||||
let outcome: 'aborted' | void;
|
||||
if (signal) {
|
||||
outcome = await new Promise<'aborted' | void>((resolve, reject) => {
|
||||
const abort = (): void => reject(signal.reason);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
void sharedSetup.then(result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
resolve(result);
|
||||
});
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
outcome = await sharedSetup;
|
||||
}
|
||||
if (outcome === 'aborted') {
|
||||
if (ffmpegSetupPromise === sharedSetup) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServeSimActivityPolling = {
|
||||
/**
|
||||
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
|
||||
function isServeSimDownError(err: unknown): boolean {
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A changed newest event ID counts as activity, timed by the local clock. */
|
||||
export function startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): ServeSimActivityPolling {
|
||||
const controller = new AbortController();
|
||||
let lastEventId: number | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarn = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const eventId = await fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
if (eventId !== undefined && eventId !== lastEventId) {
|
||||
lastEventId = eventId;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down serve-sim, which receives no input either, keeps the last observation.
|
||||
eventLogUnavailable = !isServeSimDownError(err);
|
||||
if (!didWarn) {
|
||||
didWarn = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
eventLogUnavailable
|
||||
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
|
||||
'idle timeout does not stop this session.'
|
||||
: 'Could not read serve-sim preview activity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number | undefined> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`serve-sim event log request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let body: { events?: unknown } | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error('serve-sim event log response has no events array.');
|
||||
}
|
||||
if (body.events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('serve-sim event log entry has no numeric ID.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "25.2.0"
|
||||
"@expo/steps": "25.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
|
||||
import {
|
||||
DeviceRunSessionEvent,
|
||||
parseDeviceRunSessionEvents,
|
||||
quoteAndroidShellCommand,
|
||||
validateAndroidLaunchOptions,
|
||||
} from '../deviceRunSession';
|
||||
|
||||
describe('parseDeviceRunSessionEvents', () => {
|
||||
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
|
||||
@@ -33,6 +38,56 @@ describe('parseDeviceRunSessionEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(quoteAndroidShellCommand, () => {
|
||||
it('quotes only arguments the device shell would split or expand', () => {
|
||||
expect(
|
||||
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
|
||||
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
|
||||
});
|
||||
|
||||
it('rejects NUL characters', () => {
|
||||
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateAndroidLaunchOptions, () => {
|
||||
it('rejects only commands that are too long without the launch component', () => {
|
||||
const fixedLength = 'am start --es key -n'.length;
|
||||
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
|
||||
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
|
||||
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
|
||||
});
|
||||
|
||||
it('counts bytes, not characters', () => {
|
||||
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
|
||||
const fixedLength = "am start --es key '' -n".length;
|
||||
const launchArgs = (extraLength: number) => [
|
||||
'--es',
|
||||
'key',
|
||||
'a'.repeat(extraLength) + 'é'.repeat(32_488),
|
||||
];
|
||||
|
||||
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the URL to open', () => {
|
||||
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
|
||||
return JSON.stringify(createEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UserError } from './errors';
|
||||
|
||||
/**
|
||||
* Canonical record format for the activity events emitted during a remote device run (simulator)
|
||||
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
|
||||
@@ -46,3 +48,54 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
|
||||
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
|
||||
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
|
||||
|
||||
/**
|
||||
* Check Android launch inputs before a session starts. The launch component is only known once the
|
||||
* build is installed, so this rejects only commands that are too long without it; the launch itself
|
||||
* checks the exact command.
|
||||
*/
|
||||
export function validateAndroidLaunchOptions({
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}): void {
|
||||
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
|
||||
if (openUrl) {
|
||||
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
|
||||
* each argument is quoted to reach the command as one literal word.
|
||||
*/
|
||||
export function quoteAndroidShellCommand(command: string[]): string[] {
|
||||
if (command.some(argument => argument.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Android "launch_args" and "open_url" cannot contain NUL characters.'
|
||||
);
|
||||
}
|
||||
const quotedCommand = command.map(quoteAndroidShellArgument);
|
||||
if (
|
||||
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
|
||||
);
|
||||
}
|
||||
return quotedCommand;
|
||||
}
|
||||
|
||||
function quoteAndroidShellArgument(argument: string): string {
|
||||
if (/^[\w@%+=:,./-]+$/.test(argument)) {
|
||||
return argument;
|
||||
}
|
||||
return `'${argument.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+179
-161
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/eas-json": "25.2.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/eas-json": "25.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -69,6 +69,7 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ function hasBillingPermission(permissions: readonly Permission[]): boolean {
|
||||
* Whether the viewer can manage billing on the account with the credential used for this command.
|
||||
* The scopes of a scoped credential cap `effectivePermissions`.
|
||||
*/
|
||||
function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
export function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
return hasBillingPermission(account.viewerUserPermission2.effectivePermissions);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { BillingClient } from '../../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../../billing/openUrl';
|
||||
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 { getMockViewerUserPermission } from '../../__tests__/viewerUserPermission';
|
||||
import { Permission, Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
isEligibleForFreeTierLimitCheckout,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
resolveFreeTierLimitCheckoutVariantAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../../billing/billingClient');
|
||||
jest.mock('../../billing/openUrl');
|
||||
jest.mock('../../ora');
|
||||
jest.mock('../../prompts');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-checkout';
|
||||
const ACCOUNT_NAME = 'acme';
|
||||
const AUTHENTICATION_INFO = { accessToken: 'token', sessionSecret: null } as const;
|
||||
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id', platform: Platform.ANDROID };
|
||||
const LIMIT_MESSAGE = 'Error 1\nRun eas billing:subscribe starter --account acme to upgrade to the Starter plan.';
|
||||
|
||||
/** 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_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
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_CHECKOUT_EXPERIMENT_NAMESPACE, { accountId }),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
function createOverriddenClient(variant: string): ExperimentationClient {
|
||||
return ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([[FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])]])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function account(
|
||||
id: string,
|
||||
role: Parameters<typeof getMockViewerUserPermission>[0],
|
||||
{ effectivePermissions }: { effectivePermissions?: Permission[] } = {}
|
||||
): Actor['accounts'][number] {
|
||||
const viewerUserPermission2 = getMockViewerUserPermission(role);
|
||||
return {
|
||||
id,
|
||||
name: ACCOUNT_NAME,
|
||||
viewerUserPermission2: {
|
||||
...viewerUserPermission2,
|
||||
effectivePermissions: effectivePermissions ?? viewerUserPermission2.effectivePermissions,
|
||||
},
|
||||
} as Actor['accounts'][number];
|
||||
}
|
||||
|
||||
function createExperiment(
|
||||
accountId: string,
|
||||
{
|
||||
client = createClient().client,
|
||||
accounts = [account(accountId, Role.Admin)],
|
||||
nonInteractive = false,
|
||||
runFromCI = false,
|
||||
analytics = createMockAnalytics(),
|
||||
}: {
|
||||
client?: ExperimentationClient;
|
||||
accounts?: Actor['accounts'];
|
||||
nonInteractive?: boolean;
|
||||
runFromCI?: boolean;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
} = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: { id: 'actor-id', accounts } as Actor,
|
||||
accountId,
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive,
|
||||
runFromCI,
|
||||
};
|
||||
}
|
||||
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe(getFreeTierLimitCheckoutExperiment, () => {
|
||||
const ctx = {
|
||||
analytics: createMockAnalytics(),
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
user: { id: 'actor-id', accounts: [] } as unknown as Actor,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
};
|
||||
|
||||
it('returns the handle when the command has an experimentation client and auth info', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
})
|
||||
).toEqual({
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: ctx.user,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({ ...ctx, authenticationInfo: AUTHENTICATION_INFO })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no auth info', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation: new LazyExperimentationClient(async () => createClient().client),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEligibleForFreeTierLimitCheckout, () => {
|
||||
it.each([
|
||||
['admins', Role.Admin],
|
||||
['owners', Role.Owner],
|
||||
] as const)('accepts %s at an interactive terminal', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects admins whose credential scopes do not allow managing billing', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', {
|
||||
accounts: [
|
||||
account('account-id', Role.Admin, { effectivePermissions: [Permission.View] }),
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['developers', Role.Developer],
|
||||
['view-only members', Role.ViewOnly],
|
||||
] as const)('rejects %s', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects actors who are not a member of the account', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('other-account-id', Role.Admin)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-interactive runs', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { nonInteractive: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs from CI', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { runFromCI: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs driven by a coding agent', () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
|
||||
expect(isEligibleForFreeTierLimitCheckout(createExperiment('account-id'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitCheckoutVariantAsync, () => {
|
||||
it('does not load the client for ineligible runs', async () => {
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync({
|
||||
...createExperiment(treatmentAccountId, { nonInteractive: true }),
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client });
|
||||
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).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 resolveFreeTierLimitCheckoutVariantAsync(createExperiment(controlAccountId, { client }))
|
||||
).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 { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync(
|
||||
createExperiment(treatmentAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeOfferFreeTierLimitCheckoutAsync, () => {
|
||||
const createCheckoutSessionAsync = jest.fn();
|
||||
const spinner = { start: jest.fn(), stop: jest.fn(), fail: jest.fn() };
|
||||
let logErrorSpy: jest.SpyInstance;
|
||||
let logLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
jest
|
||||
.mocked(BillingClient)
|
||||
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
|
||||
logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
logLogSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
function treatmentExperiment(
|
||||
options: Parameters<typeof createExperiment>[1] = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return createExperiment('account-id', { client: createOverriddenClient('treatment'), ...options });
|
||||
}
|
||||
|
||||
it('does nothing when the command has no experiment handle', async () => {
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, undefined)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing in the control arm', async () => {
|
||||
const experiment = createExperiment('account-id', { client: createOverriddenClient('control') });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for ineligible runs even in the treatment arm', async () => {
|
||||
const experiment = treatmentExperiment({ nonInteractive: true });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints the error, then asks with No as the default', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, treatmentExperiment())).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(logErrorSpy).toHaveBeenCalledWith(LIMIT_MESSAGE);
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
{ message: 'Open the Starter plan checkout in your browser now?', initial: false },
|
||||
{ onCancel: expect.any(Function) }
|
||||
);
|
||||
expect(logErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(confirmAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the process alive on Ctrl+C so the build error still reports', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(undefined as unknown as boolean);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
const [, options] = jest.mocked(confirmAsync).mock.calls[0];
|
||||
expect(options?.onCancel?.({} as any, {})).toBe(false);
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'cancel', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('creates nothing when the person declines', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'no', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the Starter checkout when the person accepts', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: 'https://checkout.test/cs_1' });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).toHaveBeenCalledWith(AUTHENTICATION_INFO);
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
|
||||
expect(spinner.stop).toHaveBeenCalled();
|
||||
expect(spinner.fail).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).toHaveBeenCalledWith('https://checkout.test/cs_1', {
|
||||
label: 'Checkout page',
|
||||
open: true,
|
||||
});
|
||||
expect(logLogSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session request fails', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockRejectedValue(new Error('network down'));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session has no URL', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: null });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,16 +24,27 @@ import {
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../freeTierLimitCheckout', () => ({
|
||||
...jest.requireActual('../freeTierLimitCheckout'),
|
||||
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckoutExperiment;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** An experiment handle whose client is forced to the given variant through an override. */
|
||||
@@ -94,11 +105,15 @@ const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
|
||||
|
||||
const assertReThrownError = (
|
||||
caughtError: CombinedError | Error,
|
||||
expectedType: typeof EasCommandError,
|
||||
expectedMessage: string = 'Error 1'
|
||||
expectedType: new (...args: any[]) => Error,
|
||||
expectedMessage: string = 'Error 1',
|
||||
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
|
||||
): void => {
|
||||
expect(caughtError).toBeInstanceOf(expectedType);
|
||||
expect((caughtError as Error).message).toEqual(expectedMessage);
|
||||
expect((caughtError as EasCommandError).messageAlreadyPrinted ?? false).toBe(
|
||||
messageAlreadyPrinted
|
||||
);
|
||||
};
|
||||
|
||||
describe(Build.name, () => {
|
||||
@@ -449,6 +464,153 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the checkout experiment', () => {
|
||||
it('offers the checkout with the limit message and marks the message as printed', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
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 expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the checkout for the iOS limit error too', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
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 expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the message for the command to print when nothing was offered', 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,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledTimes(1);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
{ messageAlreadyPrinted: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the agent guidance along when both experiments are present', async () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
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] });
|
||||
|
||||
await getErrorAsync(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
createExperiment('treatment'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
[
|
||||
'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'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the checkout for 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,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).not.toHaveBeenCalled();
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from './freeTierLimitCheckout';
|
||||
import {
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
@@ -51,9 +56,9 @@ import {
|
||||
maybeWarnAboutProjectTarballSize,
|
||||
reviewAndCommitChangesAsync,
|
||||
} from './utils/repository';
|
||||
import { getAccountBillingUrl } from './utils/url';
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { withAnalyticsAsync } from '../analytics/common';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { formatStarterSubscribeCommand } from '../billing/plans';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
@@ -228,7 +233,8 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
error,
|
||||
job.platform,
|
||||
ctx.accountName,
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx)
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx),
|
||||
getFreeTierLimitCheckoutExperiment(ctx)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -254,7 +260,8 @@ export async function handleBuildRequestErrorAsync(
|
||||
error: any,
|
||||
platform: Platform,
|
||||
accountName?: string,
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
|
||||
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
|
||||
): Promise<never> {
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
|
||||
@@ -270,15 +277,19 @@ export async function handleBuildRequestErrorAsync(
|
||||
const isFreeTierLimitError =
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
|
||||
throw new ErrorClass(
|
||||
isFreeTierLimitError
|
||||
? await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
)
|
||||
: message
|
||||
if (!isFreeTierLimitError) {
|
||||
throw new ErrorClass(message);
|
||||
}
|
||||
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
);
|
||||
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
|
||||
limitMessage,
|
||||
freeTierLimitCheckoutExperiment
|
||||
);
|
||||
throw new ErrorClass(limitMessage, { messageAlreadyPrinted });
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
|
||||
throw new EasBuildDownForMaintenanceError(
|
||||
`EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
@@ -695,7 +706,7 @@ async function handleSingleBuildProgressAsync(
|
||||
} else {
|
||||
updateSpinnerText(spinner, {
|
||||
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
getAccountBillingUrl(accountName)
|
||||
)}.`,
|
||||
});
|
||||
}
|
||||
@@ -718,7 +729,7 @@ async function handleSingleBuildProgressAsync(
|
||||
if (build.priority !== BuildPriority.High) {
|
||||
Log.newLine();
|
||||
Log.log('Start builds sooner in the priority queue.');
|
||||
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
|
||||
Log.log(`Sign up for a paid plan at ${link(getAccountBillingUrl(accountName))}`);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
@@ -910,10 +921,6 @@ function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatAccountBillingUrl(accountName: string): string {
|
||||
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<{
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import { BuildResourceClass } from '../graphql/generated';
|
||||
import { GradleBuildContext } from '../project/android/gradle';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { XcodeBuildContext } from '../project/ios/scheme';
|
||||
import { Actor } from '../user/User';
|
||||
@@ -68,4 +69,5 @@ export interface BuildContext<T extends Platform> {
|
||||
whatToTest?: string;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
@@ -48,6 +49,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
whatToTest,
|
||||
env,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
buildProfile: BuildProfile<T>;
|
||||
@@ -73,6 +75,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
whatToTest?: string;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env,
|
||||
@@ -163,6 +166,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
env,
|
||||
whatToTest,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getAccountBillingUrl } from './utils/url';
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { BillingClient } from '../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../billing/openUrl';
|
||||
import { SUBSCRIBABLE_PLANS } from '../billing/plans';
|
||||
import { canManageBilling } from '../billing/resolveAccount';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export const FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE = 'cli-build-free-tier-limit-checkout';
|
||||
|
||||
export type FreeTierLimitCheckoutVariant = 'control' | 'treatment';
|
||||
|
||||
export type FreeTierLimitCheckoutPromptAnswer = 'yes' | 'no' | 'cancel';
|
||||
|
||||
export type FreeTierLimitCheckoutExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
authenticationInfo: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
actor: Actor;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
nonInteractive: boolean;
|
||||
runFromCI: boolean;
|
||||
};
|
||||
|
||||
const STARTER_PLAN = SUBSCRIBABLE_PLANS.starter;
|
||||
|
||||
export function getFreeTierLimitCheckoutExperiment(ctx: {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
user: Actor;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
nonInteractive: boolean;
|
||||
runFromCI: boolean;
|
||||
}): FreeTierLimitCheckoutExperiment | undefined {
|
||||
if (!ctx.experimentation || !ctx.authenticationInfo) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
experimentation: ctx.experimentation,
|
||||
authenticationInfo: ctx.authenticationInfo,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ctx.analyticsEventProperties,
|
||||
actor: ctx.user,
|
||||
accountId: ctx.accountId,
|
||||
accountName: ctx.accountName,
|
||||
nonInteractive: ctx.nonInteractive,
|
||||
runFromCI: ctx.runFromCI,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a person at an interactive terminal who can manage billing for the account is eligible.
|
||||
* Non-interactive runs, CI, coding agents, and credentials that cannot manage billing are not.
|
||||
*/
|
||||
export function isEligibleForFreeTierLimitCheckout({
|
||||
actor,
|
||||
accountId,
|
||||
nonInteractive,
|
||||
runFromCI,
|
||||
}: FreeTierLimitCheckoutExperiment): boolean {
|
||||
if (nonInteractive || runFromCI || getAgentTelemetryContext()) {
|
||||
return false;
|
||||
}
|
||||
const account = actor.accounts.find(candidate => candidate.id === accountId);
|
||||
return account !== undefined && canManageBilling(account);
|
||||
}
|
||||
|
||||
export async function resolveFreeTierLimitCheckoutVariantAsync(
|
||||
experiment: FreeTierLimitCheckoutExperiment
|
||||
): Promise<FreeTierLimitCheckoutVariant> {
|
||||
if (!isEligibleForFreeTierLimitCheckout(experiment)) {
|
||||
return 'control';
|
||||
}
|
||||
const client = await experiment.experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, {
|
||||
accountId: experiment.accountId,
|
||||
})
|
||||
.getParam<FreeTierLimitCheckoutVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
async function createCheckoutUrlAsync({
|
||||
authenticationInfo,
|
||||
accountId,
|
||||
}: FreeTierLimitCheckoutExperiment): Promise<string | null> {
|
||||
const spinner = ora(`Creating a checkout session for the ${STARTER_PLAN.label} plan`).start();
|
||||
try {
|
||||
const session = await new BillingClient(authenticationInfo).createCheckoutSessionAsync(
|
||||
accountId,
|
||||
STARTER_PLAN.planType
|
||||
);
|
||||
if (!session.url) {
|
||||
throw new Error('The checkout session did not include a URL.');
|
||||
}
|
||||
spinner.stop();
|
||||
return session.url;
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create a checkout session');
|
||||
Log.debug(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In the treatment arm, prints the limit error and offers to open the Starter plan checkout.
|
||||
*/
|
||||
export async function maybeOfferFreeTierLimitCheckoutAsync(
|
||||
message: string,
|
||||
experiment: FreeTierLimitCheckoutExperiment | undefined
|
||||
): Promise<boolean> {
|
||||
if (!experiment || (await resolveFreeTierLimitCheckoutVariantAsync(experiment)) !== 'treatment') {
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.error(message);
|
||||
// Ctrl+C resolves to no answer instead of exiting, so the build error below still reports.
|
||||
const confirmed: boolean | undefined = await confirmAsync(
|
||||
{
|
||||
message: `Open the ${STARTER_PLAN.label} plan checkout in your browser now?`,
|
||||
initial: false,
|
||||
},
|
||||
{ onCancel: () => false }
|
||||
);
|
||||
const answer: FreeTierLimitCheckoutPromptAnswer =
|
||||
confirmed === true ? 'yes' : confirmed === false ? 'no' : 'cancel';
|
||||
|
||||
let checkoutCreated = false;
|
||||
if (answer === 'yes') {
|
||||
const checkoutUrl = await createCheckoutUrlAsync(experiment);
|
||||
if (checkoutUrl) {
|
||||
checkoutCreated = true;
|
||||
await openOrPrintUrlAsync(checkoutUrl, { label: 'Checkout page', open: true });
|
||||
} else {
|
||||
Log.log(`Upgrade your plan at ${link(getAccountBillingUrl(experiment.accountName))}`);
|
||||
}
|
||||
}
|
||||
|
||||
experiment.analytics.logEvent(BuildEvent.FREE_TIER_LIMIT_CHECKOUT_PROMPT, {
|
||||
...experiment.analyticsEventProperties,
|
||||
answer,
|
||||
checkout_created: checkoutCreated,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/que
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
@@ -120,6 +121,7 @@ export async function runBuildAndSubmitAsync({
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
@@ -130,8 +132,10 @@ export async function runBuildAndSubmitAsync({
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
/** Enables the Free plan limit guidance experiment for builds driven by a coding agent. */
|
||||
/** Enables the Free plan limit experiments on the build request error path. */
|
||||
experimentation?: LazyExperimentationClient;
|
||||
/** Lets the Free plan limit checkout experiment create a checkout session. */
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
@@ -245,6 +249,7 @@ export async function runBuildAndSubmitAsync({
|
||||
env,
|
||||
easJsonAccessor,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
@@ -384,6 +389,7 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
easJsonAccessor,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
@@ -399,6 +405,7 @@ async function prepareAndStartBuildAsync({
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
buildProfileName: buildProfile.profileName,
|
||||
@@ -425,6 +432,7 @@ async function prepareAndStartBuildAsync({
|
||||
whatToTest: flags.whatToTest,
|
||||
env,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
});
|
||||
|
||||
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
|
||||
|
||||
@@ -18,6 +18,10 @@ export function getProjectPageUrl(
|
||||
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
export function getAccountBillingUrl(accountName: string): string {
|
||||
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
const { app } = build;
|
||||
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
|
||||
|
||||
@@ -271,7 +271,9 @@ export default abstract class EasCommand extends Command {
|
||||
const commandId = this.id ?? 'unknown';
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
Log.error(err.message);
|
||||
if (!err.messageAlreadyPrinted) {
|
||||
Log.error(err.message);
|
||||
}
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanGQLErrorsMessage = err?.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
|
||||
@@ -215,6 +215,30 @@ describe('EasCommand', () => {
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('skips the message when the command already printed it', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const { EasCommandError } = require('../errors');
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
|
||||
cb({ setTag: jest.fn(), setExtra: jest.fn() });
|
||||
});
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const error = new EasCommandError('Already printed', { messageAlreadyPrinted: true });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export class EasCommandError extends Error {
|
||||
// constructor is not useless, since the constructor for Error allows for optional `message`
|
||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||
constructor(message: string) {
|
||||
/** True when the command already printed the message, so `EasCommand.catch` must not print it again. */
|
||||
public readonly messageAlreadyPrinted: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
|
||||
) {
|
||||
super(message);
|
||||
this.messageAlreadyPrinted = messageAlreadyPrinted;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default class Build extends EasCommand {
|
||||
const flags = this.sanitizeFlags(rawFlags);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
loggedIn: { actor, graphqlClient, authenticationInfo },
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
@@ -185,6 +185,7 @@ export default class Build extends EasCommand {
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
});
|
||||
|
||||
this.maybeSuggestUsingEasBuildDev({
|
||||
|
||||
@@ -40,6 +40,7 @@ jest.mock('../../../utils/json');
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
@@ -108,6 +109,7 @@ describe(SimulatorGet, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
@@ -162,6 +164,79 @@ describe(SimulatorGet, () => {
|
||||
previewApiUrl: null,
|
||||
},
|
||||
artifacts: session.artifacts,
|
||||
errors: [],
|
||||
});
|
||||
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('errored sessions', () => {
|
||||
const jobRunErrors = {
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [],
|
||||
};
|
||||
|
||||
it('includes the job run errors in JSON output', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the job run errors when only the job run errored', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
|
||||
})
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.gray).toHaveBeenCalledWith('Session errors:');
|
||||
expect(Log.error).toHaveBeenCalledWith(
|
||||
' No available iOS Simulator is named "iPhone 16 Pro".'
|
||||
);
|
||||
});
|
||||
|
||||
it('prints no errors section when the job run errors cannot be fetched', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.gray).not.toHaveBeenCalledWith('Session errors:');
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GraphQLError } from 'graphql';
|
||||
import { SimulatorEvent, isAnalyticsOptedOutAsync } from '../../../analytics/AnalyticsManager';
|
||||
import { getAgentTelemetryContext } from '../../../analytics/agent';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import fetch from '../../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
@@ -44,6 +45,7 @@ jest.mock('../../../analytics/AnalyticsManager', () => ({
|
||||
jest.mock('../../../analytics/agent');
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
|
||||
jest.mock('../../../fetch');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
@@ -57,6 +59,7 @@ jest.mock('../../../log', () => ({
|
||||
withTick: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
learnMore: jest.fn((url: string) => `Learn more: ${url}`),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
@@ -105,6 +108,8 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
);
|
||||
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const mockFetch = jest.mocked(fetch);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockFlushAsync = jest.fn(async () => {});
|
||||
@@ -191,6 +196,7 @@ describe(Simulator, () => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
|
||||
@@ -1124,6 +1130,76 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --system-image in the Android create options', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--system-image',
|
||||
' system-images;android-35-ext15;google_apis_playstore;x86_64 ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
android: {
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --device and --system-image together in the Android create options', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
'pixel_9',
|
||||
'--system-image',
|
||||
'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
android: {
|
||||
deviceIdentifier: 'pixel_9',
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a blank --system-image', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--system-image',
|
||||
' ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('android');
|
||||
});
|
||||
|
||||
it('rejects --system-image for iOS before creating a session', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--system-image',
|
||||
'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--system-image is only supported with --platform android.'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims --device before sending it', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -1920,6 +1996,292 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('job run errors', () => {
|
||||
const erroredSessionMessage =
|
||||
'Simulator session session-123 errored before the agent-device session was ready. https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123';
|
||||
const logFileUrl = 'https://logs.example.com/job-123';
|
||||
const unknownError = {
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'Unknown error. See logs for more information.',
|
||||
docsUrl: null,
|
||||
};
|
||||
|
||||
// Shaped like the worker's bunyan output: the failing call logs at error level with `err`,
|
||||
// then the step runner logs an end-step marker with result "fail".
|
||||
function failedStepLogLines(
|
||||
stepId: string,
|
||||
displayName: string,
|
||||
messages: string[]
|
||||
): Record<string, unknown>[] {
|
||||
return [
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
marker: 'start-step',
|
||||
msg: `Executing build step "${displayName}"`,
|
||||
},
|
||||
...messages.map(msg => ({
|
||||
level: 50,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
msg,
|
||||
err: { message: msg },
|
||||
})),
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: stepId,
|
||||
buildStepDisplayName: displayName,
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: `Build step "${displayName}" failed`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mockLogFile(logLines: Record<string, unknown>[]): void {
|
||||
mockFetch.mockResolvedValue({
|
||||
text: async () => logLines.map(line => JSON.stringify(line)).join('\n'),
|
||||
} as unknown as Awaited<ReturnType<typeof fetch>>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the message unchanged without reading logs when the job run has no errors', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({ message: erroredSessionMessage })
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists each job run error on its own line with its docs link', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
|
||||
docsUrl: null,
|
||||
},
|
||||
{
|
||||
errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE',
|
||||
message: 'The build cannot be installed on a simulator.',
|
||||
docsUrl: 'https://docs.expo.dev/simulator',
|
||||
},
|
||||
],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nNo available iOS Simulator is named "iPhone 16 Pro".\nThe build cannot be installed on a simulator. Learn more: https://docs.expo.dev/simulator`,
|
||||
})
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the failed step error from the job run logs for an unknown error', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Install application',
|
||||
msg: 'Retrying install',
|
||||
err: { message: 'Retrying install' },
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished',
|
||||
},
|
||||
...failedStepLogLines('step-002', 'Launch application', [
|
||||
'adb shell am start exited with non-zero code: 1',
|
||||
]),
|
||||
{
|
||||
level: 20,
|
||||
buildStepId: 'step-002',
|
||||
msg: 'Getting value for output parameter "x" failed.',
|
||||
err: { message: 'not set' },
|
||||
},
|
||||
{
|
||||
level: 40,
|
||||
msg: 'Could not stop the emulator during remote session teardown.',
|
||||
err: { message: 'teardown' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`,
|
||||
})
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
logFileUrl,
|
||||
expect.objectContaining({ size: 20 * 1024 * 1024, signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the masked log message instead of the unmasked err.message', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Run script',
|
||||
msg: 'curl -H "Authorization: ********" exited with non-zero code: 22',
|
||||
err: { message: 'curl -H "Authorization: secret-token" exited with non-zero code: 22' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Run script" failed',
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nRun script: curl -H "Authorization: ********" exited with non-zero code: 22`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the first failed step, then only the error line of later failed steps', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
...failedStepLogLines('step-001', 'Boot', ['A', 'B']),
|
||||
...failedStepLogLines('step-002', 'Finish recordings', ['C', 'D']),
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nBoot: B\nFinish recordings: D`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates very long log errors', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile(failedStepLogLines('step-001', 'Boot', ['x'.repeat(2000)]));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\n${`Boot: ${'x'.repeat(2000)}`.slice(0, 500)}…`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the job run error when no step failed, ignoring errors that steps ignored', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message:
|
||||
'Unknown error. See logs of the Complete job build phase for more information.',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockLogFile([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Upload artifact',
|
||||
msg: 'Failed to upload screen-recording. Ignoring error.',
|
||||
err: { message: 'upload failed' },
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished',
|
||||
},
|
||||
{ level: 50, phase: 'COMPLETE_JOB', msg: '', err: { message: 'upload outputs failed' } },
|
||||
]);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nUnknown error. See logs of the Complete job build phase for more information.`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the unknown error message when the job run logs cannot be read', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
mockFetch.mockRejectedValue(new Error('Request failed: 503 (Service Unavailable)'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: `${erroredSessionMessage}\nUnknown error. See logs for more information.`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the message unchanged when the job run errors cannot be fetched', async () => {
|
||||
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({ message: erroredSessionMessage })
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps polling until the remote config appears', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
|
||||
@@ -2219,6 +2581,31 @@ describe(Simulator, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
|
||||
});
|
||||
|
||||
it('includes the job run error when the session errors while it is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_SIMULATOR_SESSION_CRASHED',
|
||||
message: 'The simulator stopped responding.',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [],
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: 'Simulator session session-123 errored.\nThe simulator stopped responding.',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps waiting while the session is still in progress', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import {
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
JobRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
@@ -15,6 +19,11 @@ import {
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
SimulatorSessionError,
|
||||
fetchSimulatorSessionErrorsAsync,
|
||||
formatSimulatorSessionError,
|
||||
} from '../../simulator/jobRunErrors';
|
||||
import {
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
@@ -87,6 +96,11 @@ export default class SimulatorGet extends EasCommand {
|
||||
session.app.slug,
|
||||
session.id
|
||||
);
|
||||
const errors: SimulatorSessionError[] =
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
session.turtleJobRun?.status === JobRunStatus.Errored
|
||||
? await fetchSimulatorSessionErrorsAsync(graphqlClient, session.id)
|
||||
: [];
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
@@ -109,6 +123,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
)
|
||||
: session.remoteConfig,
|
||||
artifacts: session.artifacts,
|
||||
errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -116,6 +131,14 @@ export default class SimulatorGet extends EasCommand {
|
||||
Log.newLine();
|
||||
Log.log(formatSessionFields(session, deviceRunSessionUrl));
|
||||
|
||||
if (errors.length > 0) {
|
||||
Log.newLine();
|
||||
Log.gray('Session errors:');
|
||||
for (const error of errors) {
|
||||
Log.error(` ${formatSimulatorSessionError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (session.status === DeviceRunSessionStatus.InProgress) {
|
||||
Log.newLine();
|
||||
if (session.remoteConfig) {
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
|
||||
import {
|
||||
fetchSimulatorSessionErrorsAsync,
|
||||
formatSimulatorSessionError,
|
||||
} from '../../simulator/jobRunErrors';
|
||||
import {
|
||||
simulatorRequestFailureReason,
|
||||
simulatorRequestProperties,
|
||||
@@ -100,6 +104,10 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
|
||||
}),
|
||||
'system-image': Flags.string({
|
||||
description:
|
||||
'Android SDK system image package to use for the emulator (e.g. "system-images;android-35-ext15;google_apis_playstore;x86_64"). Defaults to a system image chosen by the runner. Only supported with --platform android.',
|
||||
}),
|
||||
'build-id': Flags.string({
|
||||
description: 'EAS Build to install and launch before the simulator session is ready.',
|
||||
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
|
||||
@@ -248,6 +256,7 @@ export default class Simulator extends EasCommand {
|
||||
const name = flags.name?.trim() || undefined;
|
||||
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const systemImagePackage = flags['system-image']?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
@@ -291,6 +300,9 @@ export default class Simulator extends EasCommand {
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
if (systemImagePackage && platform !== AppPlatform.Android) {
|
||||
throw new EasCommandError('--system-image is only supported with --platform android.');
|
||||
}
|
||||
let egressAllow: string[] = [];
|
||||
try {
|
||||
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
|
||||
@@ -366,11 +378,18 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
networkCapture: flags['network-capture'],
|
||||
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
|
||||
...(deviceIdentifier
|
||||
? platform === AppPlatform.Ios
|
||||
...(platform === AppPlatform.Ios
|
||||
? deviceIdentifier
|
||||
? { ios: { deviceIdentifier } }
|
||||
: { android: { deviceIdentifier } }
|
||||
: {}),
|
||||
: {}
|
||||
: deviceIdentifier || systemImagePackage
|
||||
? {
|
||||
android: {
|
||||
...(deviceIdentifier ? { deviceIdentifier } : {}),
|
||||
...(systemImagePackage ? { systemImagePackage } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(buildFingerprint ? { buildFingerprint } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
@@ -446,7 +465,7 @@ export default class Simulator extends EasCommand {
|
||||
session.status === DeviceRunSessionStatus.Stopped
|
||||
) {
|
||||
throw new Error(
|
||||
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
|
||||
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -457,7 +476,7 @@ export default class Simulator extends EasCommand {
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
throw new Error(
|
||||
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
|
||||
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,6 +638,18 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the reasons the session's job run failed, one per line, or an empty string when none are available.
|
||||
async function formatJobRunErrorsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId, {
|
||||
signal,
|
||||
});
|
||||
return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join('');
|
||||
}
|
||||
|
||||
async function resolvePlatformAsync(
|
||||
platform: PlatformFlagValue | undefined,
|
||||
nonInteractive: boolean
|
||||
@@ -700,7 +731,9 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
jobRunStatus === JobRunStatus.Errored
|
||||
) {
|
||||
spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`);
|
||||
throw new Error(`Simulator session ${deviceRunSessionId} errored.`);
|
||||
throw new Error(
|
||||
`Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, signal)}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Stopped ||
|
||||
|
||||
+7
@@ -16018,6 +16018,13 @@ export type DeviceRunSessionEventsByIdQueryVariables = Exact<{
|
||||
|
||||
export type DeviceRunSessionEventsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, downloadUrl: string, metadata?: any | null }> } } };
|
||||
|
||||
export type DeviceRunSessionJobRunErrorsByIdQueryVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } };
|
||||
|
||||
export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OperationResult, OperationResultSource } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
DeviceRunSessionEventsByIdQuery,
|
||||
DeviceRunSessionEventsByIdQueryVariables,
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionJobRunErrorsByIdQuery,
|
||||
DeviceRunSessionJobRunErrorsByIdQueryVariables,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
DeviceRunSessionsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -42,6 +45,52 @@ export const DeviceRunSessionQuery = {
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
/**
|
||||
* Best-effort lookup of why the session's job run failed. It doesn't retry or print errors, and it
|
||||
* rejects with `signal.reason` and cancels the request when `signal` aborts.
|
||||
*/
|
||||
async jobRunErrorsByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string,
|
||||
{ signal }: { signal: AbortSignal }
|
||||
): Promise<DeviceRunSessionJobRunErrorsByIdQuery['deviceRunSessions']['byId']['turtleJobRun']> {
|
||||
const { data, error } = await firstResultUntilAbortedAsync(
|
||||
graphqlClient.query<
|
||||
DeviceRunSessionJobRunErrorsByIdQuery,
|
||||
DeviceRunSessionJobRunErrorsByIdQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
turtleJobRun {
|
||||
id
|
||||
errors {
|
||||
errorCode
|
||||
message
|
||||
docsUrl
|
||||
}
|
||||
logFileUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
// Retries would keep a timer alive after the caller gives up.
|
||||
{ requestPolicy: 'network-only', noRetry: true }
|
||||
),
|
||||
signal
|
||||
);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error('Returned query result data is null!');
|
||||
}
|
||||
return data.deviceRunSessions.byId.turtleJobRun;
|
||||
},
|
||||
async byIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
@@ -224,3 +273,41 @@ export const DeviceRunSessionQuery = {
|
||||
return data.app.byId.deviceRunSessionsPaginated;
|
||||
},
|
||||
};
|
||||
|
||||
// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request.
|
||||
async function firstResultUntilAbortedAsync<T extends OperationResult>(
|
||||
source: OperationResultSource<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
let subscription: { unsubscribe: () => void } | undefined;
|
||||
let settled = false;
|
||||
const settle = (finish: () => void): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
subscription?.unsubscribe();
|
||||
finish();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
settle(() => {
|
||||
reject(signal.reason);
|
||||
});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
subscription = source.subscribe(result => {
|
||||
if (!result.stale && !result.hasNext) {
|
||||
settle(() => {
|
||||
resolve(result);
|
||||
});
|
||||
}
|
||||
});
|
||||
// The result can arrive synchronously, before `subscription` is assigned.
|
||||
if (settled) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import http from 'http';
|
||||
import { AddressInfo } from 'net';
|
||||
|
||||
import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import Log from '../../../log';
|
||||
import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery';
|
||||
|
||||
jest.mock('../../../api', () => ({
|
||||
getExpoApiBaseUrl: () => process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL,
|
||||
}));
|
||||
|
||||
describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => {
|
||||
let server: http.Server;
|
||||
let requests: number;
|
||||
let closedRequests: number;
|
||||
|
||||
async function startServerAsync(handler: http.RequestListener): Promise<void> {
|
||||
requests = 0;
|
||||
closedRequests = 0;
|
||||
server = http.createServer((request, response) => {
|
||||
requests += 1;
|
||||
request.socket.on('close', () => {
|
||||
closedRequests += 1;
|
||||
});
|
||||
handler(request, response);
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL = `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function respondWithJson(body: unknown): http.RequestListener {
|
||||
return (_request, response) => {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
|
||||
it('returns the job run errors', async () => {
|
||||
const turtleJobRun = {
|
||||
__typename: 'JobRun',
|
||||
id: 'job-123',
|
||||
logFileUrls: ['https://logs.example.com/job-123'],
|
||||
errors: [
|
||||
{
|
||||
__typename: 'JobRunError',
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'Unknown error.',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
await startServerAsync(
|
||||
respondWithJson({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
__typename: 'DeviceRunSessionQuery',
|
||||
byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
|
||||
await expect(
|
||||
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).resolves.toEqual(turtleJobRun);
|
||||
});
|
||||
|
||||
it('rejects without printing anything on a transient GraphQL error', async () => {
|
||||
const logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
await startServerAsync(
|
||||
respondWithJson({
|
||||
data: null,
|
||||
errors: [{ message: 'Try again', extensions: { isTransient: true } }],
|
||||
})
|
||||
);
|
||||
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
|
||||
await expect(
|
||||
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Try again');
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it('does not retry a network error', async () => {
|
||||
await startServerAsync(request => {
|
||||
request.socket.destroy();
|
||||
});
|
||||
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
|
||||
await expect(
|
||||
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects and cancels the request when the signal aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
await startServerAsync(() => {
|
||||
controller.abort(new Error('interrupted'));
|
||||
});
|
||||
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
|
||||
await expect(
|
||||
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('interrupted');
|
||||
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
expect(closedRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects without sending a request when the signal has already aborted', async () => {
|
||||
await startServerAsync(() => {});
|
||||
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('interrupted'));
|
||||
|
||||
await expect(
|
||||
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('interrupted');
|
||||
expect(requests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import http from 'http';
|
||||
import { AddressInfo } from 'net';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import type LogType from '../../log';
|
||||
import type * as JobRunErrors from '../jobRunErrors';
|
||||
|
||||
jest.mock('../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const unknownError = {
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'Unknown error. See logs for more information.',
|
||||
docsUrl: null,
|
||||
};
|
||||
|
||||
// src/fetch.ts reads https_proxy when it loads, so load it only after clearing the proxy.
|
||||
let jobRunErrors: typeof JobRunErrors;
|
||||
let Log: typeof LogType;
|
||||
beforeAll(() => {
|
||||
delete process.env.https_proxy;
|
||||
jest.isolateModules(() => {
|
||||
jobRunErrors = jest.requireActual('../jobRunErrors');
|
||||
Log = jest.requireMock('../../log').default;
|
||||
});
|
||||
});
|
||||
|
||||
// Lines 116-121 of a real failed Android session log, trimmed. The step's stderr has the precise
|
||||
// cause at level 30; the level-50 error line only has the exit code.
|
||||
const failedLaunchLog = [
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'install_build',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished build step "Install build" successfully',
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'install_build',
|
||||
source: 'stderr',
|
||||
msg: 'Error: Unable to find emulator skin, using default',
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-002',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
marker: 'start-step',
|
||||
msg: 'Executing build step "Launch application"',
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-002',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
source: 'stdout',
|
||||
msg: 'Starting: Intent { act=android.intent.action.MAIN pkg=world }',
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-002',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
source: 'stderr',
|
||||
msg: 'Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-002',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
msg: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
|
||||
err: {
|
||||
message: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-002',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Launch application" failed',
|
||||
},
|
||||
];
|
||||
|
||||
describe('selectFailedStepErrors', () => {
|
||||
it("shows the failed step's stderr cause before its error line", () => {
|
||||
expect(jobRunErrors.selectFailedStepErrors(failedLaunchLog)).toEqual([
|
||||
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
|
||||
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores stderr from steps that did not fail', () => {
|
||||
const messages = jobRunErrors.selectFailedStepErrors(failedLaunchLog);
|
||||
expect(messages.join('\n')).not.toContain('emulator skin');
|
||||
});
|
||||
|
||||
it('never prints the unmasked err.message of a phase failure with an empty message', () => {
|
||||
const phaseFailureLog = [
|
||||
{ level: 30, phase: 'PREPARE_PROJECT', msg: 'Start phase: PREPARE_PROJECT' },
|
||||
{
|
||||
level: 50,
|
||||
phase: 'PREPARE_PROJECT',
|
||||
msg: '',
|
||||
err: {
|
||||
message: 'git clone https://[email protected]/acme/app failed',
|
||||
stack: 'Error: git clone https://[email protected]/acme/app failed',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores errors outside of failed steps, such as errors a step ignored', () => {
|
||||
expect(
|
||||
jobRunErrors.selectFailedStepErrors([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Upload artifact',
|
||||
msg: 'Failed to upload screen-recording. Ignoring error.',
|
||||
err: { message: 'upload failed' },
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'success',
|
||||
msg: 'Finished',
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
msg: 'simctl boot failed for ********',
|
||||
err: { message: 'simctl boot failed for secret-udid' },
|
||||
},
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the stderr line that continues the cause', () => {
|
||||
expect(
|
||||
jobRunErrors.selectFailedStepErrors([
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Boot simulator',
|
||||
source: 'stderr',
|
||||
msg: 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
|
||||
},
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Boot simulator',
|
||||
source: 'stderr',
|
||||
msg: 'Invalid device: iPhone 16 Pro',
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Boot simulator',
|
||||
msg: 'xcrun simctl boot exited with non-zero code: 149',
|
||||
err: { message: 'xcrun simctl boot exited with non-zero code: 149' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Boot simulator" failed',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'Boot simulator: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
|
||||
'Boot simulator: Invalid device: iPhone 16 Pro',
|
||||
'Boot simulator: xcrun simctl boot exited with non-zero code: 149',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the cause of the first failed step when later steps also fail', () => {
|
||||
expect(
|
||||
jobRunErrors.selectFailedStepErrors([
|
||||
...failedLaunchLog,
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-003',
|
||||
buildStepDisplayName: 'Finish recordings',
|
||||
source: 'stderr',
|
||||
msg: 'Error: No devices are booted.',
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-003',
|
||||
buildStepDisplayName: 'Finish recordings',
|
||||
msg: 'xcrun simctl io recordVideo exited with non-zero code: 1',
|
||||
err: { message: 'x' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-003',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Finish recordings" failed',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
|
||||
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
|
||||
'Finish recordings: xcrun simctl io recordVideo exited with non-zero code: 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips escape sequences and control characters from step names and messages', () => {
|
||||
expect(
|
||||
jobRunErrors.selectFailedStepErrors([
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Boot\u001b[2J simulator',
|
||||
msg: '\u001b[31mxcrun simctl boot\u001b[0m exited\r\nwith code: 149\u0007',
|
||||
err: { message: 'x' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Boot simulator" failed',
|
||||
},
|
||||
])
|
||||
).toEqual(['Boot simulator: xcrun simctl boot exited with code: 149']);
|
||||
});
|
||||
|
||||
it('finds a colored stderr cause and skips error lines that are only escape sequences', () => {
|
||||
expect(
|
||||
jobRunErrors.selectFailedStepErrors([
|
||||
{
|
||||
level: 30,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
source: 'stderr',
|
||||
msg: '\u001b[31mError\u001b[0m: Activity not started',
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
msg: 'adb shell am start exited with non-zero code: 1',
|
||||
err: { message: 'x' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
buildStepDisplayName: 'Launch application',
|
||||
msg: '\u001b[0m\u001b[K',
|
||||
err: { message: 'x' },
|
||||
},
|
||||
{
|
||||
level: 50,
|
||||
buildStepId: 'step-001',
|
||||
marker: 'end-step',
|
||||
result: 'fail',
|
||||
msg: 'Build step "Launch application" failed',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'Launch application: Error: Activity not started',
|
||||
'Launch application: adb shell am start exited with non-zero code: 1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSimulatorSessionErrorsAsync', () => {
|
||||
let server: http.Server;
|
||||
let closedRequests: number;
|
||||
|
||||
async function startServerAsync(handler: http.RequestListener): Promise<string> {
|
||||
closedRequests = 0;
|
||||
server = http.createServer((request, response) => {
|
||||
request.socket.on('close', () => {
|
||||
closedRequests += 1;
|
||||
});
|
||||
handler(request, response);
|
||||
});
|
||||
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}/logs`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
async function waitForCloseAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
|
||||
function mockJobRun(logFileUrl: string): void {
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [unknownError],
|
||||
logFileUrls: [logFileUrl],
|
||||
});
|
||||
}
|
||||
|
||||
it('returns the failed step errors from the downloaded log', async () => {
|
||||
mockJobRun(
|
||||
await startServerAsync((_request, response) => {
|
||||
response.end(failedLaunchLog.map(line => JSON.stringify(line)).join('\n'));
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
|
||||
).resolves.toEqual([
|
||||
{
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: expect.stringContaining('Activity not started'),
|
||||
docsUrl: null,
|
||||
},
|
||||
{
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: expect.stringContaining('exited with non-zero code: 1'),
|
||||
docsUrl: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not log the signed log URL when the download fails', async () => {
|
||||
const logFileUrl = await startServerAsync((request, response) => {
|
||||
request.socket.destroy();
|
||||
response.end();
|
||||
});
|
||||
mockJobRun(`${logFileUrl}?X-Goog-Signature=secret-signature`);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
|
||||
).resolves.toEqual([unknownError]);
|
||||
expect(JSON.stringify(jest.mocked(Log.debug).mock.calls)).not.toContain('secret-signature');
|
||||
});
|
||||
|
||||
it('strips escape sequences and control characters from job run error messages', async () => {
|
||||
server = http.createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue({
|
||||
id: 'job-123',
|
||||
errors: [
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "\u001b[2JiPhone\u001b]0;x\u0007\nFake"',
|
||||
docsUrl: null,
|
||||
},
|
||||
],
|
||||
logFileUrls: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
|
||||
).resolves.toEqual([
|
||||
{
|
||||
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
message: 'No available iOS Simulator is named "iPhone Fake"',
|
||||
docsUrl: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the job run error when the log request fails', async () => {
|
||||
mockJobRun(
|
||||
await startServerAsync((_request, response) => {
|
||||
response.statusCode = 404;
|
||||
response.end(JSON.stringify({ level: 50, msg: 'not the cause', err: { message: 'x' } }));
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
|
||||
).resolves.toEqual([unknownError]);
|
||||
});
|
||||
|
||||
it('falls back and closes the connection when the log is too large', async () => {
|
||||
mockJobRun(
|
||||
await startServerAsync((_request, response) => {
|
||||
const chunk = 'x'.repeat(64 * 1024);
|
||||
const timer = setInterval(() => response.write(chunk), 5);
|
||||
response.on('close', () => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
|
||||
maxLogSizeBytes: 256 * 1024,
|
||||
})
|
||||
).resolves.toEqual([unknownError]);
|
||||
await waitForCloseAsync();
|
||||
expect(closedRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back and closes the connection when the log download stalls', async () => {
|
||||
mockJobRun(
|
||||
await startServerAsync((_request, response) => {
|
||||
response.write('{"level":30,');
|
||||
})
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
|
||||
logDownloadTimeoutMs: 200,
|
||||
})
|
||||
).resolves.toEqual([unknownError]);
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
await waitForCloseAsync();
|
||||
expect(closedRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('stops the log download when the signal aborts', async () => {
|
||||
mockJobRun(
|
||||
await startServerAsync((_request, response) => {
|
||||
response.write('{"level":30,');
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 100);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).resolves.toEqual([unknownError]);
|
||||
await waitForCloseAsync();
|
||||
expect(closedRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('does not look up errors when the signal has already aborted', async () => {
|
||||
server = http.createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes an abortable signal with a deadline to the errors query', async () => {
|
||||
server = http.createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
mockJobRunErrorsByIdAsync.mockImplementation(
|
||||
async (_client, _id, { signal }) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
|
||||
queryTimeoutMs: 100,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { parseLogLines } from '../commandUtils/logs/parseLogs';
|
||||
import { RawLogLine } from '../commandUtils/logs/types';
|
||||
import fetch from '../fetch';
|
||||
import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { sanitizeTerminalText } from '../utils/terminalText';
|
||||
|
||||
export type SimulatorSessionError = {
|
||||
errorCode: string;
|
||||
message: string;
|
||||
docsUrl?: string | null;
|
||||
};
|
||||
|
||||
export type FetchSimulatorSessionErrorsOptions = {
|
||||
/** Aborts the lookup, e.g. on Ctrl+C. */
|
||||
signal?: AbortSignal;
|
||||
queryTimeoutMs?: number;
|
||||
logDownloadTimeoutMs?: number;
|
||||
maxLogSizeBytes?: number;
|
||||
};
|
||||
|
||||
// ErrorCode.UNKNOWN_ERROR and ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR from @expo/eas-build-job.
|
||||
const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERROR']);
|
||||
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_LOGGED_ERRORS = 4;
|
||||
const MAX_LOGGED_ERROR_LENGTH = 500;
|
||||
// Bunyan's "error" level.
|
||||
const ERROR_LOG_LEVEL = 50;
|
||||
const STDERR_CAUSE_PATTERN = /\b(error|exception|fatal)\b/i;
|
||||
|
||||
type JobRunLogLine = RawLogLine & { level?: number; source?: string };
|
||||
|
||||
// Returns why the session's job run failed, or an empty list when that is not available.
|
||||
export async function fetchSimulatorSessionErrorsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string,
|
||||
{
|
||||
signal,
|
||||
queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS,
|
||||
logDownloadTimeoutMs = DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS,
|
||||
maxLogSizeBytes = DEFAULT_MAX_LOG_SIZE_BYTES,
|
||||
}: FetchSimulatorSessionErrorsOptions = {}
|
||||
): Promise<SimulatorSessionError[]> {
|
||||
if (signal?.aborted) {
|
||||
return [];
|
||||
}
|
||||
let jobRun;
|
||||
try {
|
||||
jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, {
|
||||
signal: withTimeout(signal, queryTimeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
Log.debug(`Failed to fetch simulator session errors: ${err}`);
|
||||
return [];
|
||||
}
|
||||
const errors = jobRun?.errors ?? [];
|
||||
// Uncategorized failures only say "Unknown error. See logs", so read the real cause from the job run logs.
|
||||
const logFileUrl = jobRun?.logFileUrls[0];
|
||||
if (
|
||||
logFileUrl &&
|
||||
errors.length > 0 &&
|
||||
errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode))
|
||||
) {
|
||||
const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl, {
|
||||
signal: withTimeout(signal, logDownloadTimeoutMs),
|
||||
maxLogSizeBytes,
|
||||
});
|
||||
if (logErrors.length > 0) {
|
||||
return logErrors.map(message => ({ errorCode: errors[0].errorCode, message, docsUrl: null }));
|
||||
}
|
||||
}
|
||||
return errors.map(({ errorCode, message, docsUrl }) => ({
|
||||
errorCode,
|
||||
message: formatMessage(message),
|
||||
docsUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatSimulatorSessionError(error: SimulatorSessionError): string {
|
||||
return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`;
|
||||
}
|
||||
|
||||
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
}
|
||||
|
||||
async function fetchJobRunLogErrorsAsync(
|
||||
logFileUrl: string,
|
||||
{ signal, maxLogSizeBytes }: { signal: AbortSignal; maxLogSizeBytes: number }
|
||||
): Promise<string[]> {
|
||||
// node-fetch stops reading on its own only when the body ends, so abort to close the connection
|
||||
// after an error, an oversized body or a timeout.
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const response = await fetch(logFileUrl, {
|
||||
size: maxLogSizeBytes,
|
||||
signal: AbortSignal.any([signal, controller.signal]),
|
||||
});
|
||||
const { logLines } = parseLogLines(await response.text());
|
||||
return selectFailedStepErrors(logLines);
|
||||
} catch (err) {
|
||||
// node-fetch's error messages include the signed log URL, so leave the message out.
|
||||
Log.debug(
|
||||
`Failed to read simulator session logs: ${err instanceof Error ? err.name : 'unknown error'}`
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Describes the steps that failed, the first one first: its last error line, preceded by the stderr
|
||||
// line that names the cause and the line after it. Later failed steps only add their error line.
|
||||
// Uses the log message rather than err.message because only the message has secrets masked.
|
||||
export function selectFailedStepErrors(logLines: JobRunLogLine[]): string[] {
|
||||
const failedStepIds = [
|
||||
...new Set(
|
||||
logLines
|
||||
.filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId)
|
||||
.map(line => line.buildStepId as string)
|
||||
),
|
||||
];
|
||||
const messages = failedStepIds.flatMap((stepId, index) => {
|
||||
const lines = selectStepFailureLines(logLines.filter(line => line.buildStepId === stepId));
|
||||
return (index === 0 ? lines : lines.slice(-1)).map(formatLogLine);
|
||||
});
|
||||
return [...new Set(messages)].slice(0, MAX_LOGGED_ERRORS);
|
||||
}
|
||||
|
||||
function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] {
|
||||
let selected: JobRunLogLine[] = [];
|
||||
let stderrCause: JobRunLogLine[] = [];
|
||||
for (const [index, line] of stepLines.entries()) {
|
||||
const text = toPlainText(line.msg ?? '');
|
||||
if (line.source === 'stderr') {
|
||||
if (STDERR_CAUSE_PATTERN.test(text)) {
|
||||
stderrCause = [line];
|
||||
} else if (stderrCause.length === 1 && stepLines[index - 1] === stderrCause[0]) {
|
||||
stderrCause.push(line);
|
||||
}
|
||||
} else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && text) {
|
||||
selected = [...stderrCause, line];
|
||||
stderrCause = [];
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function formatLogLine(line: JobRunLogLine): string {
|
||||
return formatMessage(
|
||||
line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg)
|
||||
);
|
||||
}
|
||||
|
||||
// Log text comes from commands run in the session, so strip escape sequences and control
|
||||
// characters that could clear the terminal or spoof output.
|
||||
function toPlainText(message: string): string {
|
||||
return sanitizeTerminalText(stripVTControlCharacters(message));
|
||||
}
|
||||
|
||||
function formatMessage(message: string): string {
|
||||
const text = toPlainText(message);
|
||||
return text.length > MAX_LOGGED_ERROR_LENGTH
|
||||
? `${text.slice(0, MAX_LOGGED_ERROR_LENGTH)}…`
|
||||
: text;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "25.2.0",
|
||||
"@expo/build-tools": "25.3.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "25.2.0",
|
||||
"version": "25.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "25.2.0",
|
||||
"@expo/build-tools": "25.3.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/template-file": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -39,6 +39,7 @@ describe(createBuildContext.name, () => {
|
||||
buildId: 'build-id',
|
||||
buildLogger,
|
||||
addBuildLogMask: jest.fn(),
|
||||
createLogMaskingStream: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TransformCallback, Writable } from 'node:stream';
|
||||
import { Readable, Transform, TransformCallback, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -185,6 +186,100 @@ describe('logger', () => {
|
||||
expect(logs.map(log => log.msg)).toEqual(['token: ***************, prefix: ***']);
|
||||
});
|
||||
|
||||
describe('structured fields', () => {
|
||||
async function logWithSecretAsync(
|
||||
log: (
|
||||
logger: Awaited<ReturnType<typeof createBuildLoggerWithSecretsFilter>>['logger']
|
||||
) => void
|
||||
): Promise<any[]> {
|
||||
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({
|
||||
environmentSecrets: [
|
||||
{ name: 'TOKEN', value: 'secret-token', type: EnvironmentSecretType.STRING },
|
||||
{ name: 'PIN', value: '123456789', type: EnvironmentSecretType.STRING },
|
||||
],
|
||||
});
|
||||
const logs: any[] = [];
|
||||
outputStream.pipe(
|
||||
new Writable({
|
||||
objectMode: true,
|
||||
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
logs.push(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
})
|
||||
);
|
||||
log(logger);
|
||||
await waitForStreamFlush();
|
||||
// The upload streams send JSON.stringify(record).
|
||||
for (const record of logs) {
|
||||
expect(JSON.stringify(record)).not.toContain('secret-token');
|
||||
expect(JSON.stringify(record)).not.toContain('123456789');
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
it('masks errors, including when the message is empty', async () => {
|
||||
const err = new Error('git clone https://[email protected]/acme/app failed');
|
||||
const logs = await logWithSecretAsync(logger => {
|
||||
logger.error({ err }, '');
|
||||
logger.error({ err });
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
for (const log of logs) {
|
||||
expect(log.err.message).toBe('git clone https://************@github.com/acme/app failed');
|
||||
expect(log.err.stack).toContain(
|
||||
'git clone https://************@github.com/acme/app failed'
|
||||
);
|
||||
}
|
||||
expect(logs[0].msg).toBe('');
|
||||
expect(logs[1].msg).toBe('git clone https://************@github.com/acme/app failed');
|
||||
});
|
||||
|
||||
it('masks other fields, keys and numbers, and keeps metadata fields', async () => {
|
||||
const job = { env: { TOKEN_COPY: 'secret-token', 'secret-token': 'key', PIN: 123456789 } };
|
||||
const logs = await logWithSecretAsync(logger => {
|
||||
logger.info({ job, source: 'stdout', buildStepId: 'step-1' }, 'Builder is ready');
|
||||
});
|
||||
|
||||
expect(logs[0].job).toEqual({
|
||||
env: { TOKEN_COPY: '************', '************': 'key', PIN: '*********' },
|
||||
});
|
||||
expect(logs[0].source).toBe('stdout');
|
||||
expect(logs[0].buildStepId).toBe('step-1');
|
||||
// The caller's object, which bunyan also hands to other streams, is not changed.
|
||||
expect(job.env.TOKEN_COPY).toBe('secret-token');
|
||||
});
|
||||
|
||||
it('masks what a toJSON method returns', async () => {
|
||||
const logs = await logWithSecretAsync(logger => {
|
||||
logger.error(
|
||||
{
|
||||
err: {
|
||||
token: 'secret-token',
|
||||
toJSON: () => ({ token: 'secret-token', at: new Date(0) }),
|
||||
},
|
||||
},
|
||||
'failed'
|
||||
);
|
||||
});
|
||||
|
||||
expect(logs[0].err).toEqual({ token: '************', at: '1970-01-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('truncates deeply nested values instead of uploading them unmasked', async () => {
|
||||
let nested: any = { token: 'secret-token' };
|
||||
for (let index = 0; index < 30; index++) {
|
||||
nested = { nested };
|
||||
}
|
||||
const logs = await logWithSecretAsync(logger => {
|
||||
logger.error({ err: nested }, 'failed');
|
||||
});
|
||||
|
||||
expect(JSON.stringify(logs[0].err)).toContain('[Truncated]');
|
||||
});
|
||||
});
|
||||
|
||||
it('adds logId to each log', async () => {
|
||||
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
|
||||
|
||||
@@ -262,3 +357,99 @@ describe('logger', () => {
|
||||
expect(outputStream.readableLength).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLogMaskingStream', () => {
|
||||
async function maskAsync(stream: Transform, chunks: Buffer[]): Promise<Buffer> {
|
||||
const output: Buffer[] = [];
|
||||
await pipeline(
|
||||
Readable.from(chunks),
|
||||
stream,
|
||||
new Writable({
|
||||
write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
output.push(chunk);
|
||||
callback();
|
||||
},
|
||||
})
|
||||
);
|
||||
return Buffer.concat(output);
|
||||
}
|
||||
|
||||
it('masks secrets, secrets as Xcode prints them in build settings, and decoded secrets', async () => {
|
||||
const { createLogMaskingStream } = await createBuildLoggerWithSecretsFilter({
|
||||
environmentSecrets: [
|
||||
{ name: 'API_KEY', value: 'ab cd=ef$gh', type: EnvironmentSecretType.STRING },
|
||||
{
|
||||
name: 'ANOTHER_SECRET_BASE64',
|
||||
value: 'YW5vdGhlclNlY3JldA==',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
],
|
||||
});
|
||||
const log = [
|
||||
' export API_KEY\\=ab\\ cd\\=ef\\$gh',
|
||||
'token is ab cd=ef$gh',
|
||||
'decoded anotherSecret here',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const masked = await maskAsync(createLogMaskingStream(), [Buffer.from(log)]);
|
||||
|
||||
expect(masked.toString()).toBe(
|
||||
[
|
||||
` export API_KEY\\=${'*'.repeat(14)}`,
|
||||
`token is ${'*'.repeat(11)}`,
|
||||
`decoded ${'*'.repeat(13)} here`,
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('masks secrets split across chunks, including masks added after the logger was created', async () => {
|
||||
const { createLogMaskingStream, addBuildLogMask } = await createBuildLoggerWithSecretsFilter(
|
||||
{}
|
||||
);
|
||||
addBuildLogMask('leased-token');
|
||||
|
||||
const masked = await maskAsync(createLogMaskingStream(), [
|
||||
Buffer.from('first line leased-'),
|
||||
Buffer.from('token\nsecond '),
|
||||
Buffer.from('leased-token without a newline'),
|
||||
]);
|
||||
|
||||
expect(masked.toString()).toBe(
|
||||
`first line ${'*'.repeat(12)}\nsecond ${'*'.repeat(12)} without a newline`
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps everything except secrets byte for byte, including multibyte and invalid UTF-8', async () => {
|
||||
const { createLogMaskingStream } = await createBuildLoggerWithSecretsFilter({
|
||||
environmentSecrets: [
|
||||
{ name: 'PASSWORD', value: 'pässwörd', type: EnvironmentSecretType.STRING },
|
||||
],
|
||||
});
|
||||
const log = Buffer.concat([
|
||||
Buffer.from('✓ Compiling café.swift\r\n'),
|
||||
Buffer.from([0xff, 0xfe, 0x0a]),
|
||||
Buffer.from('password: pässwörd\n'),
|
||||
]);
|
||||
// Split inside the multibyte characters.
|
||||
const chunks = [
|
||||
log.subarray(0, 1),
|
||||
log.subarray(1, 20),
|
||||
log.subarray(20, 40),
|
||||
log.subarray(40),
|
||||
];
|
||||
|
||||
const masked = await maskAsync(createLogMaskingStream(), chunks);
|
||||
|
||||
const passwordBytes = Buffer.byteLength('pässwörd');
|
||||
expect(masked).toEqual(
|
||||
Buffer.concat([
|
||||
Buffer.from('✓ Compiling café.swift\r\n'),
|
||||
Buffer.from([0xff, 0xfe, 0x0a]),
|
||||
Buffer.from(`password: ${'*'.repeat(passwordBytes)}\n`),
|
||||
])
|
||||
);
|
||||
expect(masked.length).toBe(log.length);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,7 @@ describe('BuildService Datadog setup', () => {
|
||||
createBuildLoggerWithSecretsFilterMock.mockResolvedValue({
|
||||
logger: buildLogger,
|
||||
addBuildLogMask: jest.fn(),
|
||||
createLogMaskingStream: jest.fn(),
|
||||
cleanUp: jest.fn(async () => {}),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] } as any,
|
||||
outputStream: {} as any,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { RemoteLoggerStream, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import config from '../config';
|
||||
import { uploadXcodeBuildLogs } from '../ios/xcodeLogs';
|
||||
import { createTextMaskingStream } from '../logger';
|
||||
|
||||
// These tests use real fs and directories in os.tmpdir(), since memfs read streams can't be closed.
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
|
||||
const logger = { warn: jest.fn() } as any;
|
||||
|
||||
async function readAllAsync(stream: Readable): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
describe(uploadXcodeBuildLogs, () => {
|
||||
const originalGcs = { ...config.loggers.gcs };
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xcode-logs-test-'));
|
||||
config.loggers.gcs.signedUploadUrlForXcodeBuildLogs = {
|
||||
url: 'https://gcs.expo.test/xcode-logs',
|
||||
headers: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Object.assign(config.loggers.gcs, originalGcs);
|
||||
jest.mocked(uploadWithSignedUrl).mockReset();
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no compression', null, (body: Buffer) => body],
|
||||
['gzip', RemoteLoggerStream.CompressionMethod.GZIP, zlib.gunzipSync],
|
||||
['brotli', RemoteLoggerStream.CompressionMethod.BR, zlib.brotliDecompressSync],
|
||||
])('uploads the log with secrets masked (%s)', async (_name, compressionMethod, decompress) => {
|
||||
config.loggers.gcs.compressionMethod = compressionMethod as any;
|
||||
const logsPath = path.join(tmpDir, 'xcodebuild.log');
|
||||
await fs.writeFile(logsPath, ' export API_KEY\\=ab\\ cd\\=ef\\$gh\n** BUILD SUCCEEDED **\n');
|
||||
let uploaded: Buffer | undefined;
|
||||
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
uploaded = await readAllAsync((await srcGeneratorAsync()) as Readable);
|
||||
return 'https://gcs.expo.test/xcode-logs';
|
||||
});
|
||||
|
||||
await uploadXcodeBuildLogs(logger, logsPath, () =>
|
||||
createTextMaskingStream(new Set(['ab cd=ef$gh']))
|
||||
);
|
||||
|
||||
expect(decompress(uploaded!).toString()).toBe(
|
||||
` export API_KEY\\=${'*'.repeat(14)}\n** BUILD SUCCEEDED **\n`
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no compression', null],
|
||||
['gzip', RemoteLoggerStream.CompressionMethod.GZIP],
|
||||
])('fails the upload when the log cannot be read (%s)', async (_name, compressionMethod) => {
|
||||
config.loggers.gcs.compressionMethod = compressionMethod as any;
|
||||
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
await readAllAsync((await srcGeneratorAsync()) as Readable);
|
||||
return 'https://gcs.expo.test/xcode-logs';
|
||||
});
|
||||
|
||||
// A directory can be stat'ed but not read.
|
||||
await expect(
|
||||
uploadXcodeBuildLogs(logger, tmpDir, () => createTextMaskingStream(new Set(['secret'])))
|
||||
).rejects.toThrow('EISDIR');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { BuildContext, BuildContextOptions, LogBuffer, RuntimeSettings } from '@
|
||||
import { Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import assert from 'assert';
|
||||
import { Transform } from 'stream';
|
||||
|
||||
import { GCSCacheManager } from './CacheManager';
|
||||
import config from './config';
|
||||
@@ -27,6 +28,7 @@ export async function createBuildContext<TJob extends Job>({
|
||||
buildId,
|
||||
buildLogger,
|
||||
addBuildLogMask,
|
||||
createLogMaskingStream,
|
||||
}: {
|
||||
job: TJob;
|
||||
logBuffer: LogBuffer;
|
||||
@@ -36,6 +38,7 @@ export async function createBuildContext<TJob extends Job>({
|
||||
buildId: string;
|
||||
buildLogger: bunyan;
|
||||
addBuildLogMask: (secret: string) => void;
|
||||
createLogMaskingStream: () => Transform;
|
||||
}): Promise<BuildContext<TJob>> {
|
||||
const childLogger = buildLogger.child({ buildId });
|
||||
await RuntimeSettings.loadAsync({
|
||||
@@ -51,7 +54,7 @@ export async function createBuildContext<TJob extends Job>({
|
||||
switch (type) {
|
||||
case ManagedArtifactType.XCODE_BUILD_LOGS: {
|
||||
if (job.platform === Platform.IOS) {
|
||||
await uploadXcodeBuildLogs(logger, paths[0]);
|
||||
await uploadXcodeBuildLogs(logger, paths[0], createLogMaskingStream);
|
||||
return { filename: null };
|
||||
}
|
||||
throw new Error('Uploading Xcode logs in non-iOS builds is not supported');
|
||||
|
||||
@@ -3,14 +3,18 @@ import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Readable, pipeline } from 'stream';
|
||||
import { Readable, Transform, pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import config from '../config';
|
||||
|
||||
export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Promise<void> {
|
||||
export async function uploadXcodeBuildLogs(
|
||||
logger: bunyan,
|
||||
logsPath: string,
|
||||
createLogMaskingStream: () => Transform
|
||||
): Promise<void> {
|
||||
if (!config.loggers.gcs.signedUploadUrlForXcodeBuildLogs) {
|
||||
logger.warn('GCS Presigned URL for Xcode logs was not specified, skipping upload');
|
||||
} else {
|
||||
@@ -18,8 +22,15 @@ export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Pr
|
||||
signedUrl: config.loggers.gcs.signedUploadUrlForXcodeBuildLogs,
|
||||
srcGeneratorAsync: async () => {
|
||||
const stat = await fs.stat(logsPath);
|
||||
// Masking replaces each byte of a secret with one byte, so the size stays the same.
|
||||
// pipeline passes read errors on to the masked stream, which fails the upload.
|
||||
const maskedStream = pipeline(
|
||||
fs.createReadStream(logsPath),
|
||||
createLogMaskingStream(),
|
||||
() => {}
|
||||
);
|
||||
const { stream } = await compress(
|
||||
{ stream: fs.createReadStream(logsPath), length: stat.size },
|
||||
{ stream: maskedStream, length: stat.size },
|
||||
config.loggers.gcs.compressionMethod
|
||||
);
|
||||
return stream;
|
||||
|
||||
+111
-17
@@ -59,30 +59,79 @@ export class WorkerLogBuffer extends Writable implements LogBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
// Bunyan's own fields and fields that identify a log line, which never hold user data.
|
||||
const UNMASKED_FIELDS = new Set([
|
||||
'name',
|
||||
'pid',
|
||||
'level',
|
||||
'time',
|
||||
'v',
|
||||
'phase',
|
||||
'source',
|
||||
'marker',
|
||||
'result',
|
||||
'buildStepId',
|
||||
'buildStepInternalId',
|
||||
]);
|
||||
const MAX_MASKED_DEPTH = 20;
|
||||
|
||||
function createTransformStream(secretPatterns: ReadonlySet<string>): Transform {
|
||||
return new Transform({
|
||||
readableObjectMode: true,
|
||||
writableObjectMode: true,
|
||||
transform(_chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
let chunk = _chunk;
|
||||
if (chunk && typeof chunk === 'object') {
|
||||
// Remove hostname from logs since we don't need it.
|
||||
const { hostname: _hostname, ...rest } = chunk;
|
||||
chunk = {
|
||||
...rest,
|
||||
// Add logId to each log.
|
||||
logId: uuidv7(),
|
||||
};
|
||||
if (!_chunk || typeof _chunk !== 'object') {
|
||||
callback(null, _chunk);
|
||||
return;
|
||||
}
|
||||
// Remove hostname from logs since we don't need it. Bunyan shares the record with other
|
||||
// streams, so build a copy instead of changing it.
|
||||
const { hostname: _hostname, ...rest } = _chunk;
|
||||
const chunk: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
// Secrets can be in any field, such as a serialized error's message and stack.
|
||||
chunk[key] =
|
||||
secretPatterns.size === 0 || UNMASKED_FIELDS.has(key)
|
||||
? value
|
||||
: maskSecretsInValue(value, secretPatterns);
|
||||
}
|
||||
// Add logId to each log.
|
||||
chunk.logId = uuidv7();
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk && typeof chunk === 'object' && chunk.msg) {
|
||||
callback(null, {
|
||||
...chunk,
|
||||
msg: maskSecrets(chunk.msg as string, secretPatterns),
|
||||
});
|
||||
} else {
|
||||
callback(null, chunk);
|
||||
}
|
||||
// Characters that Xcode escapes with a backslash when it prints build settings.
|
||||
const XCODE_ESCAPED_CHARACTERS = /[\t !"#$&'()*;<=>?[\\\]^`{|}~]/g;
|
||||
|
||||
/**
|
||||
* Masks secrets in a text file's contents, line by line, including secrets written the way Xcode
|
||||
* prints build settings. Masking works on bytes, so everything else stays unchanged.
|
||||
*/
|
||||
export function createTextMaskingStream(secretPatterns: ReadonlySet<string>): Transform {
|
||||
const patterns = [
|
||||
...new Set(
|
||||
[...secretPatterns].flatMap(secret => [
|
||||
secret,
|
||||
secret.replace(XCODE_ESCAPED_CHARACTERS, '\\$&'),
|
||||
])
|
||||
),
|
||||
].map(pattern => Buffer.from(pattern).toString('latin1'));
|
||||
const maskLine = (line: string): string =>
|
||||
patterns.some(pattern => line.includes(pattern)) ? maskSecrets(line, patterns) : line;
|
||||
let partialLine = '';
|
||||
return new Transform({
|
||||
transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
const lines = (partialLine + chunk.toString('latin1')).split('\n');
|
||||
partialLine = lines.pop() ?? '';
|
||||
callback(
|
||||
null,
|
||||
lines.length > 0 ? Buffer.from(`${lines.map(maskLine).join('\n')}\n`, 'latin1') : undefined
|
||||
);
|
||||
},
|
||||
flush(callback: TransformCallback) {
|
||||
callback(null, partialLine ? Buffer.from(maskLine(partialLine), 'latin1') : undefined);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -101,6 +150,48 @@ function maskSecrets(message: string, secrets: Iterable<string>): string {
|
||||
return characters.join('');
|
||||
}
|
||||
|
||||
function maskSecretsInValue(value: unknown, secrets: ReadonlySet<string>): unknown {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return maskSecretsInJsonValue(value, secrets, 0);
|
||||
}
|
||||
// Mask what the upload streams will serialize, so a toJSON method can't bring back the original.
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
return maskSecretsInJsonValue(json, secrets, 0);
|
||||
}
|
||||
|
||||
function maskSecretsInJsonValue(
|
||||
value: unknown,
|
||||
secrets: ReadonlySet<string>,
|
||||
depth: number
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return maskSecrets(value, secrets);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return secrets.has(String(value)) ? maskSecrets(String(value), secrets) : value;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (depth >= MAX_MASKED_DEPTH) {
|
||||
return '[Truncated]';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => maskSecretsInJsonValue(item, secrets, depth + 1));
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
maskSecrets(key, secrets),
|
||||
maskSecretsInJsonValue(item, secrets, depth + 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function createDiscardStream(): Writable {
|
||||
return new Writable({
|
||||
objectMode: true,
|
||||
@@ -113,6 +204,7 @@ function createDiscardStream(): Writable {
|
||||
export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']): Promise<{
|
||||
logger: bunyan;
|
||||
addBuildLogMask: (secret: string) => void;
|
||||
createLogMaskingStream: () => Transform;
|
||||
cleanUp: () => Promise<void>;
|
||||
logBuffer: WorkerLogBuffer;
|
||||
outputStream: Readable;
|
||||
@@ -182,6 +274,8 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
|
||||
return {
|
||||
logger: buildLogger,
|
||||
addBuildLogMask,
|
||||
// Reads the masks when the stream is created, so it includes masks added during the job.
|
||||
createLogMaskingStream: () => createTextMaskingStream(secretPatterns),
|
||||
cleanUp: async () => {
|
||||
await Promise.all([remoteLoggerStream?.cleanUp?.(), httpLogStream?.cleanUp?.()]);
|
||||
},
|
||||
|
||||
@@ -275,6 +275,7 @@ export default class BuildService {
|
||||
const {
|
||||
logger: buildLogger,
|
||||
addBuildLogMask,
|
||||
createLogMaskingStream,
|
||||
cleanUp,
|
||||
logBuffer,
|
||||
} = await createBuildLoggerWithSecretsFilter(job.secrets ?? {});
|
||||
@@ -315,6 +316,7 @@ export default class BuildService {
|
||||
buildId: this.buildId,
|
||||
buildLogger,
|
||||
addBuildLogMask,
|
||||
createLogMaskingStream,
|
||||
});
|
||||
this.buildContext = ctx;
|
||||
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:25.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:25.3.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/downloader": "npm:25.3.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/template-file": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@expo/steps": "npm:25.3.0"
|
||||
"@expo/template-file": "npm:25.3.0"
|
||||
"@expo/turtle-spawn": "npm:25.3.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:25.2.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:25.3.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:25.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:25.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.3.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:25.2.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:25.3.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:25.2.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:25.3.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3131,12 +3131,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:25.2.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:25.3.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3162,7 +3162,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:25.2.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:25.3.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3185,11 +3185,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:25.2.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:25.3.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3205,17 +3205,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:25.2.0"
|
||||
"@expo/build-tools": "npm:25.3.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/template-file": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@expo/steps": "npm:25.3.0"
|
||||
"@expo/template-file": "npm:25.3.0"
|
||||
"@expo/turtle-spawn": "npm:25.3.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10099,7 +10099,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/steps": "npm:25.3.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10683,12 +10683,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:25.2.0"
|
||||
"@expo/build-tools": "npm:25.3.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.3.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10742,12 +10742,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/eas-json": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.3.0"
|
||||
"@expo/eas-json": "npm:25.3.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.3.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10759,7 +10759,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/steps": "npm:25.3.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user