mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b8a78145 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 | ||
|
|
44b63e5a7d | ||
|
|
42e4fa8a7c | ||
|
|
e4bb181802 | ||
|
|
53ac936b11 | ||
|
|
c0d3f4f935 | ||
|
|
5ec3d84fcb | ||
|
|
d0cb5a2d67 | ||
|
|
aae7d13ac0 | ||
|
|
d9b1f866d4 | ||
|
|
a03dca45eb | ||
|
|
f949f3430e | ||
|
|
e61a563b70 | ||
|
|
150cd21174 | ||
|
|
4b9e792e91 |
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': 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,36 @@
|
||||
|
||||
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
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+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.1.1",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
# @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
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@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.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@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,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.1.1",
|
||||
"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.1.1"
|
||||
"@expo/steps": "25.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.1.1",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.1.1",
|
||||
"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,13 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@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,20 @@
|
||||
# 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
|
||||
|
||||
- Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
+282
-162
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/eas-json": "25.1.1",
|
||||
"@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.1.1",
|
||||
"@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.1.1",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -185,6 +185,7 @@
|
||||
],
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"helpClass": "./build/help",
|
||||
"plugins": [
|
||||
"@oclif/plugin-autocomplete",
|
||||
"@expo/plugin-help",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
let run: jest.SpyInstance;
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(showHelp).toHaveBeenCalledWith(argv);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
} from '../../../simulator/hostActions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControl from '../control';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/hostActions', () => ({
|
||||
...jest.requireActual('../../../simulator/hostActions'),
|
||||
connectHostActionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const catalog: HostActionCatalog = require('../../../simulator/__tests__/fixtures/hostActionCatalog.json');
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockConnectHostActionsAsync = jest.mocked(connectHostActionsAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeDeviceRunSession(
|
||||
overrides: Partial<DeviceRunSessionById> = {}
|
||||
): DeviceRunSessionById & { publicPreview: boolean } {
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
tags: [],
|
||||
publicPreview: false,
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
startedAt: '2025-01-01T00:00:05.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2025-01-01T00:01:00.000Z',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
slug: 'testapp',
|
||||
ownerAccount: { id: 'account-123', name: 'testuser' },
|
||||
},
|
||||
artifacts: [],
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
},
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorControl, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectDir = '/test/project';
|
||||
const ok = (stdout: string): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
let actionResult: HostActionResult;
|
||||
let served: HostActionCatalog;
|
||||
let tmpDir: string;
|
||||
const requestAsync = jest.fn(
|
||||
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
|
||||
switch (action) {
|
||||
case 'actions.describe':
|
||||
return ok(JSON.stringify(served));
|
||||
case 'upload.append':
|
||||
return ok(`/tmp/serve-sim-uploads/${params.uploadId}\n`);
|
||||
case 'upload.remove':
|
||||
return ok('');
|
||||
case 'file.readBase64':
|
||||
return ok(Buffer.from('png bytes').toString('base64'));
|
||||
default:
|
||||
return actionResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
|
||||
actionResult = ok('off\n');
|
||||
served = catalog;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorControl {
|
||||
const command = new SimulatorControl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function actionRequests(): Parameters<typeof requestAsync>[] {
|
||||
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
|
||||
}
|
||||
|
||||
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
EAS_SIMULATOR_SESSION_ID: 'env-session',
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
createCommand(['hardwareKeyboard.set', 'off', ...flags]).runAsync()
|
||||
).rejects.toThrow('--id needs a simulator session ID.');
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the environment session when --id is absent', async () => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
EAS_SIMULATOR_SESSION_ID: 'env-session',
|
||||
});
|
||||
try {
|
||||
await createCommand(['hardwareKeyboard.set', 'off']).runAsync();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'env-session');
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs an action from the catalog with its values and prints the output', async () => {
|
||||
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
|
||||
apiUrl: 'https://sim.example.test',
|
||||
token: 'preview-token',
|
||||
});
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('off');
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
|
||||
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
|
||||
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
|
||||
expect(lines.flatMap(line => line.trim().split(/\s+/)).sort()).toEqual(
|
||||
Object.keys(catalog).sort()
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
|
||||
await createCommand(['permissions.set', help, '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' Grant, revoke or reset one permission for an app.'
|
||||
);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' eas simulator:control permissions.set <action> <service> <bundleId>'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows local file guidance without validating output or uploading files', async () => {
|
||||
await createCommand([
|
||||
'screenshot.capture',
|
||||
'--help',
|
||||
'--output',
|
||||
'/missing/folder/screen.png',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' eas simulator:control screenshot.capture [fileName]'
|
||||
);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' --output <path> Local filename or existing directory.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prints the host action schema as JSON for help', async () => {
|
||||
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
action: 'app.install',
|
||||
...catalog['app.install'],
|
||||
});
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects unknown actions in help and closes the connection', async () => {
|
||||
await expect(
|
||||
createCommand(['unknown.action', '--help', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Unknown simulator action "unknown.action"');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prints parsed JSON output', async () => {
|
||||
actionResult = ok('{"voiceover":"off"}\n');
|
||||
|
||||
await createCommand(['permissions.list', '--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
action: 'permissions.list',
|
||||
output: { voiceover: 'off' },
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a local file for an action that takes one and removes it after', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the upload ID to an action that asks for it', async () => {
|
||||
const localFile = path.join(tmpDir, 'session.json');
|
||||
await fs.writeFile(localFile, '{}');
|
||||
actionResult = ok('');
|
||||
served = {
|
||||
...catalog,
|
||||
'app.file.upload': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
bundleId: { type: 'string' },
|
||||
relativePath: { type: 'string' },
|
||||
uploadId: { type: 'string' },
|
||||
},
|
||||
upload: 'uploadId',
|
||||
uploadAs: 'id',
|
||||
},
|
||||
};
|
||||
|
||||
await createCommand([
|
||||
'app.file.upload',
|
||||
'com.example.app',
|
||||
'Documents/session.json',
|
||||
localFile,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
['upload.append', { uploadId, data: Buffer.from('{}').toString('base64'), first: true }],
|
||||
[
|
||||
'app.file.upload',
|
||||
{ bundleId: 'com.example.app', relativePath: 'Documents/session.json', uploadId },
|
||||
],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an upload that the action goes on reading', async () => {
|
||||
const localFile = path.join(tmpDir, 'feed.mp4');
|
||||
await fs.writeFile(localFile, 'video bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
expect(requests.map(([action]) => action)).toEqual(['upload.append', 'camera.switch']);
|
||||
expect(requests[1][1]).toEqual({
|
||||
source: 'file',
|
||||
target: `/tmp/serve-sim-uploads/${requests[0][1]?.uploadId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a kept upload when the action fails', async () => {
|
||||
const localFile = path.join(tmpDir, 'feed.mp4');
|
||||
await fs.writeFile(localFile, 'video bytes');
|
||||
actionResult = { stdout: '', stderr: 'No such app.\n', exitCode: 1 };
|
||||
|
||||
await expect(
|
||||
createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('The simulator could not run camera.switch: No such app.');
|
||||
expect(actionRequests().map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'camera.switch',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a webcam name through without looking for a local file', async () => {
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand([
|
||||
'camera.switch',
|
||||
'webcam',
|
||||
'FaceTime HD Camera',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(actionRequests()).toEqual([
|
||||
['camera.switch', { source: 'webcam', target: 'FaceTime HD Camera' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a relative file that does not exist on this computer', async () => {
|
||||
await expect(
|
||||
createCommand(['app.install', './MyAp.ipa', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('./MyAp.ipa does not exist on this computer.');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses --output for an action that returns no file before it runs', async () => {
|
||||
await expect(
|
||||
createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--output',
|
||||
'./out',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync()
|
||||
).rejects.toThrow('hardwareKeyboard.set does not return a file, so --output does not apply.');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a path that is not a local file through unchanged', async () => {
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand([
|
||||
'app.install',
|
||||
'/Users/runner/MyApp.ipa',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n');
|
||||
|
||||
await createCommand([
|
||||
'screenshot.capture',
|
||||
'--output',
|
||||
outputPath,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(`Saved ${outputPath}`);
|
||||
});
|
||||
|
||||
it('refuses an action that the session does not list', async () => {
|
||||
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an established connection when catalog setup fails', async () => {
|
||||
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
|
||||
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'did not return its list of actions'
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
);
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Simulator session session-123 is stopped');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an Android session', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('available only for iOS sessions');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a session without a preview server API', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewToken: 'preview-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not expose a preview server');
|
||||
});
|
||||
|
||||
it('reports the simulator error when the action fails', async () => {
|
||||
actionResult = {
|
||||
stdout: '',
|
||||
stderr: 'Simulator could not change the hinge angle.\n',
|
||||
exitCode: 1,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createCommand(['hinge.set', 'half', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow(
|
||||
'The simulator could not run hinge.set: Simulator could not change the hinge angle.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../../simulator/hostActions';
|
||||
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorControl extends EasCommand {
|
||||
static override aliases = ['sim:control'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`.';
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
'$ eas simulator:control',
|
||||
'$ eas simulator:control --id <session-id>',
|
||||
'$ eas simulator:control screenshot.capture --help',
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.ipa',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png --id <session-id>',
|
||||
];
|
||||
|
||||
static override args = {
|
||||
action: Args.string({
|
||||
description:
|
||||
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
help: Flags.boolean({
|
||||
char: 'h',
|
||||
description: 'Show command help, or usage for the selected action from the session.',
|
||||
}),
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
list: Flags.boolean({
|
||||
description: 'List the actions the session supports.',
|
||||
}),
|
||||
output: Flags.string({
|
||||
description:
|
||||
'Local path to save a returned file. For screenshots, --output ./screenshot.png sets the local filename; omit it to save with a generated filename in the current directory.',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, argv, flags } = await this.parse(SimulatorControl);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
if (flags.id === '') {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
const action = flags.list && !flags.help ? undefined : args.action;
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorControl, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora(`Connecting to simulator session ${sessionId}`).start();
|
||||
let connection: HostActionConnection | undefined;
|
||||
try {
|
||||
let catalog: HostActionCatalog;
|
||||
try {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(assertControllableSession(session));
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
|
||||
throw err;
|
||||
}
|
||||
if (!action) {
|
||||
this.printCatalog(catalog, jsonFlag);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
|
||||
);
|
||||
}
|
||||
if (flags.help) {
|
||||
this.printActionHelp(action, entry, jsonFlag);
|
||||
return;
|
||||
}
|
||||
await this.runActionAsync(connection, entry, {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(catalog);
|
||||
return;
|
||||
}
|
||||
const names = Object.keys(catalog).sort();
|
||||
const width = Math.max(0, ...names.map(name => name.length)) + 3;
|
||||
const columns = Math.max(1, Math.min(3, Math.floor((process.stdout.columns || 80) / width)));
|
||||
const rows = Math.ceil(names.length / columns);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cells = Array.from({ length: columns }, (_, column) =>
|
||||
(names[row + column * rows] ?? '').padEnd(width)
|
||||
);
|
||||
Log.log(cells.join('').trimEnd());
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log('For action usage: eas simulator:control <action> --help');
|
||||
}
|
||||
|
||||
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ ...entry, action });
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold(action)}`);
|
||||
if (entry.description) {
|
||||
Log.log(` ${entry.description}`);
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold('USAGE')}`);
|
||||
Log.log(` eas simulator:control ${formatHostActionUsage(action, entry)}`);
|
||||
const outputFlag = entry.output === 'base64' ? '--output <path>' : '[--output <path>]';
|
||||
Log.log(` ${entry.output ? `${outputFlag} ` : ''}[--id <session-id>] [--json]`);
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold('OPTIONS')}`);
|
||||
Log.log(` --id <session-id> Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`);
|
||||
Log.log(' --json Print the action schema as JSON. Implies --non-interactive.');
|
||||
if (entry.output) {
|
||||
Log.log(
|
||||
entry.output === 'base64'
|
||||
? ' --output <path> Required local filename for the returned file.'
|
||||
: ' --output <path> Local filename or existing directory.'
|
||||
);
|
||||
if (entry.output === 'path') {
|
||||
Log.log(' Omit to save with the generated filename.');
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
' Action arguments refer to the simulator host. Use --output for the local filename.'
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private async runActionAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
{
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
outputPath,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
let output: string;
|
||||
let savedFile: string | null;
|
||||
try {
|
||||
if (localFile) {
|
||||
spinner.text = `Uploading ${localFile.path}`;
|
||||
const upload = await uploadFileAsync(connection, localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
spinner.text = `Running ${action} on simulator session ${sessionId}`;
|
||||
}
|
||||
const result = await connection.requestAsync(action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
|
||||
output = savedFile ? '' : result.stdout.trim();
|
||||
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
|
||||
throw err;
|
||||
} finally {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: sessionId,
|
||||
action,
|
||||
output: parseJsonOutput(output),
|
||||
file: savedFile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (savedFile) {
|
||||
Log.log(`Saved ${savedFile}`);
|
||||
} else if (output) {
|
||||
Log.log(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch(() => null);
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat && !path.isAbsolute(value)) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
}
|
||||
|
||||
function assertControllableSession(
|
||||
session: Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>
|
||||
): PreviewApiAccess {
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} runs on ${session.platform.toLowerCase()}. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
import { Help, Parser } from '@oclif/core';
|
||||
|
||||
export default class EasHelp extends Help {
|
||||
// eslint-disable-next-line async-protect/async-suffix -- oclif defines the method name.
|
||||
override async showHelp(argv: string[]): Promise<void> {
|
||||
const [command, ...args] = argv;
|
||||
if (command === 'simulator:control' || command === 'sim:control') {
|
||||
const { default: SimulatorControl } = await import('./commands/simulator/control');
|
||||
const parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
if (parsed.args.action && parsed.flags.help) {
|
||||
await SimulatorControl.run(args, this.config);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await super.showHelp(argv);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"hardwareKeyboard.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"value"
|
||||
],
|
||||
"description": "Connect or disconnect the hardware keyboard."
|
||||
},
|
||||
"caDebug.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"option": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"blended",
|
||||
"copies",
|
||||
"misaligned",
|
||||
"offscreen",
|
||||
"slow-animations"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"option",
|
||||
"value"
|
||||
],
|
||||
"description": "Turn a Core Animation debug option on or off."
|
||||
},
|
||||
"hinge.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fold",
|
||||
"half",
|
||||
"unfold"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"value"
|
||||
],
|
||||
"description": "Fold or unfold a foldable simulator."
|
||||
},
|
||||
"location.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number"
|
||||
},
|
||||
"lng": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"lat",
|
||||
"lng"
|
||||
],
|
||||
"description": "Set the simulated location."
|
||||
},
|
||||
"permissions.list": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid"
|
||||
],
|
||||
"description": "List permissions for one app or for every app."
|
||||
},
|
||||
"permissions.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grant",
|
||||
"revoke",
|
||||
"reset"
|
||||
]
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"action",
|
||||
"service",
|
||||
"bundleId"
|
||||
],
|
||||
"description": "Grant, revoke or reset one permission for an app."
|
||||
},
|
||||
"app.install": {
|
||||
"description": "Install an app from an .ipa file.",
|
||||
"args": [
|
||||
"path"
|
||||
],
|
||||
"upload": "path",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"uploadId": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"uploadId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"media.add": {
|
||||
"description": "Add a photo or video to the Photos library.",
|
||||
"args": [
|
||||
"path"
|
||||
],
|
||||
"upload": "path",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"uploadId": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"uploadId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"camera.switch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source",
|
||||
"target"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "webcam"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "placeholder"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Change the camera feed of an app with a simulated camera.",
|
||||
"args": [
|
||||
"source",
|
||||
"target"
|
||||
],
|
||||
"upload": "target",
|
||||
"uploadWhen": {
|
||||
"source": "file"
|
||||
},
|
||||
"keepUpload": true
|
||||
},
|
||||
"camera.inject": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source",
|
||||
"target"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "webcam"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "placeholder"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Relaunch an app with a simulated camera feed.",
|
||||
"args": [
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source",
|
||||
"target"
|
||||
],
|
||||
"upload": "target",
|
||||
"uploadWhen": {
|
||||
"source": "file"
|
||||
},
|
||||
"keepUpload": true
|
||||
},
|
||||
"screenshot.capture": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"pattern": "^serve-sim-screenshot-[A-Za-z0-9._-]{1,100}\\.png$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid"
|
||||
],
|
||||
"description": "Take a screenshot.",
|
||||
"output": "path"
|
||||
},
|
||||
"file.readBase64": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"description": "Download a file from the simulator host.",
|
||||
"output": "base64"
|
||||
},
|
||||
"app.container": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId"
|
||||
],
|
||||
"description": "Print the path of an installed app bundle."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import * as fetchModule from '../../fetch';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../hostActions';
|
||||
|
||||
const UDID = '90858C9D-BC34-4A0A-93D4-E4939A681A72';
|
||||
const TOKEN = 'preview-token_123';
|
||||
const catalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
|
||||
function entry(name: string): HostActionCatalog[string] {
|
||||
return catalog[name];
|
||||
}
|
||||
|
||||
describe(buildHostActionParams, () => {
|
||||
it('fills parameters in schema order', () => {
|
||||
expect(
|
||||
buildHostActionParams('permissions.set', entry('permissions.set'), [
|
||||
'grant',
|
||||
'camera',
|
||||
'com.example.app',
|
||||
])
|
||||
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
|
||||
});
|
||||
|
||||
it('converts values to the types the schema accepts', () => {
|
||||
expect(
|
||||
buildHostActionParams('location.set', entry('location.set'), ['37.3349', '-122.009'])
|
||||
).toEqual({ lat: 37.3349, lng: -122.009 });
|
||||
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['90'])).toEqual({ value: 90 });
|
||||
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['half'])).toEqual({
|
||||
value: 'half',
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['1'])
|
||||
).toEqual({ value: '1' });
|
||||
expect(buildHostActionParams('location.set', entry('location.set'), ['1e1', '.5'])).toEqual({
|
||||
lat: 10,
|
||||
lng: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads JSON for a parameter that takes a list', () => {
|
||||
const launch = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
bundleId: { type: 'string' },
|
||||
launchArgs: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildHostActionParams('app.launch', launch, ['com.example.app', '["-FIRDebugEnabled"]'])
|
||||
).toEqual({ bundleId: 'com.example.app', launchArgs: ['-FIRDebugEnabled'] });
|
||||
expect(buildHostActionParams('app.launch', launch, ['com.example.app', '[oops'])).toEqual({
|
||||
bundleId: 'com.example.app',
|
||||
launchArgs: '[oops',
|
||||
});
|
||||
});
|
||||
|
||||
it('fills the values in order around the named ones', () => {
|
||||
expect(buildHostActionParams('location.set', entry('location.set'), ['lat=1', '2'])).toEqual({
|
||||
lat: 1,
|
||||
lng: 2,
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('permissions.set', entry('permissions.set'), [
|
||||
'action=grant',
|
||||
'camera',
|
||||
'com.example.app',
|
||||
])
|
||||
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
|
||||
});
|
||||
|
||||
it('accepts named values in any order', () => {
|
||||
expect(
|
||||
buildHostActionParams('permissions.list', entry('permissions.list'), [
|
||||
'bundleId=com.example.app',
|
||||
])
|
||||
).toEqual({ bundleId: 'com.example.app' });
|
||||
expect(
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), [
|
||||
'target=./photo.jpg',
|
||||
'source=file',
|
||||
])
|
||||
).toEqual({ source: 'file', target: './photo.jpg' });
|
||||
});
|
||||
|
||||
it('follows the value order the catalog gives for actions with several shapes', () => {
|
||||
expect(buildHostActionParams('app.install', entry('app.install'), ['./MyApp.ipa'])).toEqual({
|
||||
path: './MyApp.ipa',
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './photo.jpg'])
|
||||
).toEqual({ source: 'file', target: './photo.jpg' });
|
||||
expect(
|
||||
buildHostActionParams('camera.inject', entry('camera.inject'), [
|
||||
'com.example.app',
|
||||
'off',
|
||||
'placeholder',
|
||||
])
|
||||
).toEqual({ bundleId: 'com.example.app', mirror: 'off', source: 'placeholder' });
|
||||
});
|
||||
|
||||
it('refuses extra values and a value given twice', () => {
|
||||
expect(() =>
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['on', 'off'])
|
||||
).toThrow(
|
||||
'Too many values for hardwareKeyboard.set. Usage: eas simulator:control hardwareKeyboard.set <value>.'
|
||||
);
|
||||
expect(() =>
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './a.jpg', './b.jpg'])
|
||||
).toThrow('Too many values for camera.switch.');
|
||||
expect(() =>
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), [
|
||||
'value=on',
|
||||
'value=off',
|
||||
])
|
||||
).toThrow('value is given twice for hardwareKeyboard.set.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatHostActionUsage, () => {
|
||||
it.each(['anyOf', 'oneOf'])('shows a %s input group only once', composition => {
|
||||
expect(
|
||||
formatHostActionUsage('app.install', {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bundleId: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
uploadId: { type: 'string' },
|
||||
},
|
||||
required: ['bundleId'],
|
||||
args: ['bundleId', 'path', 'uploadId'],
|
||||
[composition]: [{ required: ['path'] }, { required: ['uploadId'] }],
|
||||
})
|
||||
).toBe('app.install <bundleId> <path|uploadId=...>');
|
||||
});
|
||||
|
||||
it('shows the required alternative file sources for an upload action', () => {
|
||||
expect(formatHostActionUsage('app.install', entry('app.install'))).toBe(
|
||||
'app.install <uploadId=...|path>'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks required and optional parameters', () => {
|
||||
expect(formatHostActionUsage('permissions.set', entry('permissions.set'))).toBe(
|
||||
'permissions.set <action> <service> <bundleId>'
|
||||
);
|
||||
expect(formatHostActionUsage('screenshot.capture', entry('screenshot.capture'))).toBe(
|
||||
'screenshot.capture [fileName]'
|
||||
);
|
||||
expect(formatHostActionUsage('camera.switch', entry('camera.switch'))).toBe(
|
||||
'camera.switch <source> [target]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action transport policy', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
'http://sim.example.test',
|
||||
'http://127.0.0.1.example.test',
|
||||
'http://localhost.example.test',
|
||||
'http://[::ffff:192.0.2.1]',
|
||||
'ftp://127.0.0.1',
|
||||
'ws://127.0.0.1',
|
||||
])('rejects %s before sending the session token', async apiUrl => {
|
||||
const fetch = jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockRejectedValue(new Error('Unexpected fetch'));
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'Simulator actions require HTTPS'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://sim.example.test', 'https://sim.example.test/api'],
|
||||
['http://localhost:44370', 'http://localhost:44370/api'],
|
||||
['http://127.0.0.1:44370', 'http://127.0.0.1:44370/api'],
|
||||
['http://127.255.255.254:44370', 'http://127.255.255.254:44370/api'],
|
||||
['http://2130706433:44370', 'http://127.0.0.1:44370/api'],
|
||||
['http://[::1]:44370', 'http://[::1]:44370/api'],
|
||||
])('accepts %s without following authenticated redirects', async (apiUrl, endpoint) => {
|
||||
const stopped = new Error('Fetch reached');
|
||||
const fetch = jest.spyOn(fetchModule, 'default').mockRejectedValue(stopped);
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toBe(stopped);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
endpoint,
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
redirect: 'error',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action connection', () => {
|
||||
let server: http.Server;
|
||||
let wss: WebSocketServer;
|
||||
let apiUrl: string;
|
||||
let received: { id: number; action: string; params: Record<string, unknown> }[];
|
||||
let requests: (string | undefined)[];
|
||||
let apiBody: string;
|
||||
let uploads: Map<string, Buffer>;
|
||||
let reply: (message: {
|
||||
id: number;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}) => Record<string, unknown> | null;
|
||||
let tmpDir: string;
|
||||
let connection: HostActionConnection | undefined;
|
||||
let closeBeforeReady: boolean;
|
||||
|
||||
function serveSim(message: {
|
||||
id: number;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const ok = (stdout: string): Record<string, unknown> => ({
|
||||
id: message.id,
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
switch (message.action) {
|
||||
case 'actions.describe':
|
||||
return ok(JSON.stringify(catalog));
|
||||
case 'upload.append': {
|
||||
const uploadId = String(message.params.uploadId);
|
||||
const chunk = Buffer.from(String(message.params.data), 'base64');
|
||||
const earlier = message.params.first ? [] : [uploads.get(uploadId) ?? Buffer.alloc(0)];
|
||||
uploads.set(uploadId, Buffer.concat([...earlier, chunk]));
|
||||
return ok(`/tmp/serve-sim-uploads/${uploadId}\n`);
|
||||
}
|
||||
case 'file.readBase64':
|
||||
return ok(Buffer.from('png bytes').toString('base64'));
|
||||
default:
|
||||
return ok('on\n');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
received = [];
|
||||
requests = [];
|
||||
uploads = new Map();
|
||||
apiBody = JSON.stringify({ device: UDID });
|
||||
reply = serveSim;
|
||||
closeBeforeReady = false;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
return;
|
||||
}
|
||||
res.writeHead(401);
|
||||
res.end();
|
||||
});
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: offered =>
|
||||
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
|
||||
});
|
||||
wss.on('connection', ws => {
|
||||
if (closeBeforeReady) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
ws.on('message', data => {
|
||||
const message = JSON.parse(data.toString());
|
||||
received.push(message);
|
||||
const response = reply(message);
|
||||
if (response) {
|
||||
ws.send(JSON.stringify(response));
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connection?.close();
|
||||
connection = undefined;
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
wss.close();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
async function connectAsync(timeoutMs?: number): Promise<HostActionConnection> {
|
||||
connection = await connectHostActionsAsync({ apiUrl, token: TOKEN }, { timeoutMs });
|
||||
return connection;
|
||||
}
|
||||
|
||||
it('sends several requests over one connection with the simulator udid', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const results = await Promise.all([
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
hostActions.requestAsync('hardwareKeyboard.set', { value: 'off' }),
|
||||
]);
|
||||
|
||||
expect(received).toEqual([
|
||||
{ id: 1, action: 'hardwareKeyboard.get', params: { udid: UDID } },
|
||||
{ id: 2, action: 'hardwareKeyboard.set', params: { value: 'off', udid: UDID } },
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ stdout: 'on\n', stderr: '', exitCode: 0 },
|
||||
{ stdout: 'on\n', stderr: '', exitCode: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the catalog of actions', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(fetchHostActionCatalogAsync(hostActions)).resolves.toEqual(catalog);
|
||||
});
|
||||
|
||||
it('explains that an old serve-sim does not have the catalog', async () => {
|
||||
reply = message => ({ id: message.id, error: 'unknown action actions.describe' });
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(fetchHostActionCatalogAsync(hostActions)).rejects.toThrow(
|
||||
'does not support this action'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a validation error from the simulator through', async () => {
|
||||
reply = message => ({
|
||||
id: message.id,
|
||||
error: 'hardwareKeyboard.set: value must be one of on, off',
|
||||
});
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
hostActions.requestAsync('hardwareKeyboard.set', { value: 'maybe' })
|
||||
).rejects.toThrow(
|
||||
'The simulator refused the request: hardwareKeyboard.set: value must be one of on, off'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails a request at once on a reply it cannot read', async () => {
|
||||
reply = message => ({ id: message.id, output: 'on' });
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
|
||||
'sent a reply that this version of eas-cli cannot read'
|
||||
);
|
||||
});
|
||||
|
||||
it('gives up on a request the simulator does not answer', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync(100);
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
|
||||
'The simulator did not answer within'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a path on the API URL and drops its query', async () => {
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
|
||||
).rejects.toThrow('refused the session token (HTTP 401)');
|
||||
expect(requests).toEqual(['/preview/api']);
|
||||
});
|
||||
|
||||
it('refuses a wrong token before it opens the socket', async () => {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: 'wrong' })).rejects.toThrow(
|
||||
'refused the session token (HTTP 401)'
|
||||
);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not send the session token to an API redirect target', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api') {
|
||||
res.writeHead(302, { Location: '/redirected-api' });
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(connectAsync()).rejects.toThrow(/redirect/i);
|
||||
expect(requests).toEqual(['/api']);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('explains a connection that closes during setup', async () => {
|
||||
closeBeforeReady = true;
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
/closed the connection during setup.*token.*eas simulator:get/
|
||||
);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'closes a refused HTTP %i upgrade without waiting for its body',
|
||||
async status => {
|
||||
let rejectedSocket: Duplex | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const closed = new Promise<void>(resolve => {
|
||||
server.removeAllListeners('upgrade');
|
||||
server.once('upgrade', (_req, socket) => {
|
||||
rejectedSocket = socket;
|
||||
socket.once('close', resolve);
|
||||
socket.on('end', () => socket.end());
|
||||
socket.write(
|
||||
`HTTP/1.1 ${status} Refused\r\nConnection: keep-alive\r\nContent-Length: 1000000\r\n\r\nrefused`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
`refused the connection (HTTP ${status})`
|
||||
);
|
||||
await Promise.race([
|
||||
closed,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error('Refused upgrade socket stayed open'));
|
||||
}, 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
rejectedSocket?.destroy();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('explains a preview server that does not report its simulator', async () => {
|
||||
apiBody = '<html>starting</html>';
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'did not report which simulator it streams'
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a file in order and returns where the simulator host stored it', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'hello simulator');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const upload = await uploadFileAsync(hostActions, localFile, { chunkBytes: 4 });
|
||||
|
||||
expect(upload.uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.ipa$/);
|
||||
expect(upload.path).toBe(`/tmp/serve-sim-uploads/${upload.uploadId}`);
|
||||
expect(uploads.get(upload.uploadId)?.toString()).toBe('hello simulator');
|
||||
expect(received.map(message => message.params.first)).toEqual([true, false, false, false]);
|
||||
});
|
||||
|
||||
it('removes a partial upload when a chunk fails', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'hello simulator');
|
||||
reply = message =>
|
||||
message.action === 'upload.append' && !message.params.first
|
||||
? { id: message.id, stdout: '', stderr: 'The upload staging area is full.', exitCode: 1 }
|
||||
: serveSim(message);
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(uploadFileAsync(hostActions, localFile, { chunkBytes: 4 })).rejects.toThrow(
|
||||
`Could not upload ${localFile}: The upload staging area is full.`
|
||||
);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
action: 'upload.remove',
|
||||
params: { uploadId: received[0].params.uploadId },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to upload an empty file', async () => {
|
||||
const localFile = path.join(tmpDir, 'empty.png');
|
||||
await fs.writeFile(localFile, '');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(uploadFileAsync(hostActions, localFile)).rejects.toThrow(`${localFile} is empty.`);
|
||||
});
|
||||
|
||||
it('downloads a file the action left on the simulator host', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
|
||||
const saved = await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
outputPath
|
||||
);
|
||||
|
||||
expect(saved).toBe(outputPath);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
action: 'file.readBase64',
|
||||
params: { path: '/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png' },
|
||||
});
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
|
||||
});
|
||||
|
||||
it('saves into a folder under the name the host gave the file', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const saved = await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
tmpDir
|
||||
);
|
||||
|
||||
expect(saved).toBe(path.join(tmpDir, 'serve-sim-screenshot-1.png'));
|
||||
expect(await fs.readFile(path.join(tmpDir, 'serve-sim-screenshot-1.png'), 'utf8')).toBe(
|
||||
'png bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not replace a file it named itself', async () => {
|
||||
const existing = path.join(tmpDir, 'serve-sim-screenshot-1.png');
|
||||
await fs.writeFile(existing, 'mine');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
tmpDir
|
||||
)
|
||||
).rejects.toThrow(`${existing} already exists.`);
|
||||
expect(await fs.readFile(existing, 'utf8')).toBe('mine');
|
||||
});
|
||||
|
||||
it('saves file contents the action returned', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const outputPath = path.join(tmpDir, 'Info.plist');
|
||||
|
||||
await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('file.readBase64'),
|
||||
Buffer.from('plist').toString('base64'),
|
||||
outputPath
|
||||
);
|
||||
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('plist');
|
||||
});
|
||||
|
||||
it('saves nothing for an action that returns no file', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
saveHostActionOutputAsync(hostActions, entry('hardwareKeyboard.set'), 'on', undefined)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(assertHostActionOutputAsync, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-action-output-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('allows --output only for actions that return a file', async () => {
|
||||
await expect(
|
||||
assertHostActionOutputAsync('app.container', entry('app.container'), './out')
|
||||
).rejects.toThrow('app.container does not return a file, so --output does not apply.');
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), undefined)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), tmpDir)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('needs a file path for an action that returns file contents', async () => {
|
||||
await expect(
|
||||
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), undefined)
|
||||
).rejects.toThrow('file.readBase64 returns a file. Pass --output with the path to save it to.');
|
||||
await expect(
|
||||
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), tmpDir)
|
||||
).rejects.toThrow(`${tmpDir} is a folder. Pass --output with a file path for file.readBase64.`);
|
||||
});
|
||||
|
||||
it('refuses a path in a folder that does not exist', async () => {
|
||||
const missing = path.join(tmpDir, 'missing', 'shot.png');
|
||||
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), missing)
|
||||
).rejects.toThrow(`The folder for ${missing} does not exist.`);
|
||||
await expect(
|
||||
assertHostActionOutputAsync(
|
||||
'screenshot.capture',
|
||||
entry('screenshot.capture'),
|
||||
path.join(tmpDir, 'missing') + path.sep
|
||||
)
|
||||
).rejects.toThrow('does not exist');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getLoopbackForwardPlan,
|
||||
getPreviewApiAccess,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../utils';
|
||||
@@ -837,6 +838,45 @@ describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(getPreviewApiAccess, () => {
|
||||
it('reads the preview token for each remote config type', () => {
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.test',
|
||||
agentDeviceRemoteSessionToken: 'agent-token',
|
||||
webPreviewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
|
||||
});
|
||||
|
||||
it('returns null without an API URL or a token', () => {
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewToken: 'preview-token',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatEmbedPreviewUrl, () => {
|
||||
it('is null until the worker reports the preview host', () => {
|
||||
expect(formatEmbedPreviewUrl(agentDeviceConfig)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PreviewApiAccess } from './utils';
|
||||
import fetch, { RequestError, httpsProxyAgent } from '../fetch';
|
||||
|
||||
export type HostActionResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
export type HostActionConnection = {
|
||||
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
const SchemaNode = z.looseObject({
|
||||
type: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
allOf: z.array(z.unknown()).optional(),
|
||||
anyOf: z.array(z.unknown()).optional(),
|
||||
oneOf: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const CatalogEntry = SchemaNode.extend({
|
||||
description: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
upload: z.string().optional(),
|
||||
uploadAs: z.enum(['path', 'id']).optional(),
|
||||
uploadWhen: z.record(z.string(), z.unknown()).optional(),
|
||||
keepUpload: z.boolean().optional(),
|
||||
output: z.enum(['path', 'base64']).optional(),
|
||||
});
|
||||
|
||||
const Catalog = z.record(z.string(), CatalogEntry);
|
||||
|
||||
export type HostActionCatalog = z.infer<typeof Catalog>;
|
||||
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
|
||||
|
||||
const PreviewConfig = z.object({ device: z.string().min(1) });
|
||||
|
||||
const ReplyId = z.object({ id: z.number() });
|
||||
|
||||
const SocketMessage = z.union([
|
||||
z.object({ ready: z.literal(true) }),
|
||||
z.object({ id: z.number(), error: z.string() }),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
exitCode: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const HOST_ACTION_TIMEOUT_MS = 130_000;
|
||||
const PREVIEW_API_TIMEOUT_MS = 30_000;
|
||||
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
|
||||
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
|
||||
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
||||
|
||||
function previewEndpoint(apiUrl: string, endpoint: string): URL {
|
||||
const base = new URL(apiUrl);
|
||||
const loopback =
|
||||
base.hostname === 'localhost' ||
|
||||
base.hostname === '[::1]' ||
|
||||
/^127(?:\.\d{1,3}){3}$/.test(base.hostname);
|
||||
if (base.protocol !== 'https:' && (base.protocol !== 'http:' || !loopback)) {
|
||||
throw new Error(
|
||||
'Simulator actions require HTTPS for remote preview servers. HTTP is supported only on localhost or a loopback address. Start a new session with `eas simulator:start`.'
|
||||
);
|
||||
}
|
||||
base.search = '';
|
||||
base.hash = '';
|
||||
if (!base.pathname.endsWith('/')) {
|
||||
base.pathname += '/';
|
||||
}
|
||||
return new URL(endpoint, base);
|
||||
}
|
||||
|
||||
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
|
||||
let text: string;
|
||||
try {
|
||||
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
|
||||
throw new Error(
|
||||
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`The simulator preview server did not answer within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
const config = PreviewConfig.safeParse(body);
|
||||
if (!config.success) {
|
||||
throw new Error(
|
||||
'The simulator preview server did not report which simulator it streams. The session may still be starting. Wait a few seconds and run the command again.'
|
||||
);
|
||||
}
|
||||
return config.data.device;
|
||||
}
|
||||
|
||||
function refusedError(error: string): Error {
|
||||
return error.startsWith('unknown action')
|
||||
? new Error(
|
||||
'This simulator session runs a serve-sim version that does not support this action. Start a new session with `eas simulator:start` to get a newer version.'
|
||||
)
|
||||
: new Error(`The simulator refused the request: ${error}`);
|
||||
}
|
||||
|
||||
export async function connectHostActionsAsync(
|
||||
access: PreviewApiAccess,
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
): Promise<HostActionConnection> {
|
||||
const udid = await fetchSimulatorUdidAsync(access);
|
||||
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
|
||||
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(
|
||||
socketUrl.toString(),
|
||||
[`serve-sim.token.${access.token}`],
|
||||
httpsProxyAgent ? { agent: httpsProxyAgent } : {}
|
||||
);
|
||||
|
||||
type Pending = {
|
||||
resolve: (result: HostActionResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
const pending = new Map<number, Pending>();
|
||||
let nextId = 1;
|
||||
let lost: Error | null = null;
|
||||
let ready = false;
|
||||
|
||||
function settle(id: number): Pending | undefined {
|
||||
const request = pending.get(id);
|
||||
if (request) {
|
||||
pending.delete(id);
|
||||
clearTimeout(request.timer);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function loseConnection(error: Error): void {
|
||||
lost ??= error;
|
||||
for (const id of [...pending.keys()]) {
|
||||
settle(id)?.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
|
||||
)
|
||||
);
|
||||
ws.terminate();
|
||||
}, PREVIEW_API_TIMEOUT_MS);
|
||||
const fail = (error: Error): void => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
loseConnection(error);
|
||||
if (!ready) {
|
||||
ws.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
ws.on('message', data => {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const parsed = SocketMessage.safeParse(message);
|
||||
if (!parsed.success) {
|
||||
const id = ReplyId.safeParse(message);
|
||||
if (id.success) {
|
||||
settle(id.data.id)?.reject(
|
||||
new Error(
|
||||
'The simulator preview server sent a reply that this version of eas-cli cannot read. Update eas-cli and try again.'
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reply = parsed.data;
|
||||
if ('ready' in reply) {
|
||||
ready = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} else if ('error' in reply) {
|
||||
settle(reply.id)?.reject(refusedError(reply.error));
|
||||
} else {
|
||||
settle(reply.id)?.resolve({
|
||||
stdout: reply.stdout,
|
||||
stderr: reply.stderr,
|
||||
exitCode: reply.exitCode,
|
||||
});
|
||||
}
|
||||
});
|
||||
ws.on('unexpected-response', (_req, res) => {
|
||||
res.destroy();
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
)
|
||||
);
|
||||
});
|
||||
ws.on('error', error => {
|
||||
fail(
|
||||
new Error(
|
||||
`Could not connect to the simulator preview server: ${error.message}. Check your network connection and that the session still runs.`
|
||||
)
|
||||
);
|
||||
});
|
||||
ws.on('close', () => {
|
||||
fail(
|
||||
new Error(
|
||||
ready
|
||||
? 'The simulator preview server closed the connection before it answered. The action may still have run. Check the current state before you try again.'
|
||||
: 'The simulator preview server closed the connection during setup. The session token may be invalid or expired, or the session may run an older serve-sim version. Check the session with `eas simulator:get` before starting a new session.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async requestAsync(action, params = {}) {
|
||||
if (lost) {
|
||||
throw lost;
|
||||
}
|
||||
const id = nextId++;
|
||||
return await new Promise<HostActionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(id)?.reject(
|
||||
new Error(
|
||||
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
|
||||
});
|
||||
},
|
||||
close() {
|
||||
ws.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchHostActionCatalogAsync(
|
||||
connection: HostActionConnection
|
||||
): Promise<HostActionCatalog> {
|
||||
const result = await connection.requestAsync('actions.describe');
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(result.stdout);
|
||||
} catch {}
|
||||
const catalog = Catalog.safeParse(body);
|
||||
if (result.exitCode !== 0 || !catalog.success) {
|
||||
throw new Error(
|
||||
'The simulator did not return its list of actions. Start a new session with `eas simulator:start` to get a newer serve-sim version.'
|
||||
);
|
||||
}
|
||||
return catalog.data;
|
||||
}
|
||||
|
||||
function collectProperties(node: unknown, into: Map<string, unknown[]>): void {
|
||||
const parsed = SchemaNode.safeParse(node);
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
for (const [name, schema] of Object.entries(parsed.data.properties ?? {})) {
|
||||
into.set(name, [...(into.get(name) ?? []), schema]);
|
||||
}
|
||||
const { allOf = [], anyOf = [], oneOf = [] } = parsed.data;
|
||||
for (const child of [...allOf, ...anyOf, ...oneOf]) {
|
||||
collectProperties(child, into);
|
||||
}
|
||||
}
|
||||
|
||||
function hostActionProperties(entry: HostActionCatalogEntry): Map<string, unknown[]> {
|
||||
const properties = new Map<string, unknown[]>();
|
||||
collectProperties(entry, properties);
|
||||
properties.delete('udid');
|
||||
return properties;
|
||||
}
|
||||
|
||||
function positionalNames(entry: HostActionCatalogEntry): string[] {
|
||||
return entry.args ?? Object.keys(entry.properties ?? {}).filter(name => name !== 'udid');
|
||||
}
|
||||
|
||||
function requiredProperties(node: unknown): Set<string> {
|
||||
const parsed = SchemaNode.safeParse(node);
|
||||
if (!parsed.success) {
|
||||
return new Set();
|
||||
}
|
||||
const required = new Set(parsed.data.required ?? []);
|
||||
for (const child of parsed.data.allOf ?? []) {
|
||||
requiredProperties(child).forEach(name => required.add(name));
|
||||
}
|
||||
const branches = [...(parsed.data.anyOf ?? []), ...(parsed.data.oneOf ?? [])].map(
|
||||
requiredProperties
|
||||
);
|
||||
for (const name of branches[0] ?? []) {
|
||||
if (branches.every(branch => branch.has(name))) {
|
||||
required.add(name);
|
||||
}
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
function acceptsType(schema: unknown, types: string[]): boolean {
|
||||
const node = SchemaNode.safeParse(schema);
|
||||
if (!node.success) {
|
||||
return false;
|
||||
}
|
||||
const own = [node.data.type ?? []].flat();
|
||||
return (
|
||||
own.some(type => types.includes(type)) ||
|
||||
[...(node.data.anyOf ?? []), ...(node.data.oneOf ?? [])].some(child =>
|
||||
acceptsType(child, types)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function coerce(value: string, schemas: unknown[]): unknown {
|
||||
if (/^[[{]/.test(value) && schemas.some(schema => acceptsType(schema, ['array', 'object']))) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (NUMBER.test(value) && schemas.some(schema => acceptsType(schema, ['number', 'integer']))) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
(value === 'true' || value === 'false') &&
|
||||
schemas.some(schema => acceptsType(schema, ['boolean']))
|
||||
) {
|
||||
return value === 'true';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatHostActionUsage(name: string, entry: HostActionCatalogEntry): string {
|
||||
const required = requiredProperties(entry);
|
||||
const alternatives = (entry.anyOf ?? entry.oneOf ?? []).map(branch =>
|
||||
[...requiredProperties(branch)].filter(
|
||||
property => property !== 'udid' && !required.has(property)
|
||||
)
|
||||
);
|
||||
const requiredAlternatives =
|
||||
alternatives.length > 0 && alternatives.every(properties => properties.length === 1)
|
||||
? alternatives.map(([property]) => property)
|
||||
: [];
|
||||
let describedAlternatives = false;
|
||||
const names = positionalNames(entry).flatMap(property => {
|
||||
if (requiredAlternatives.includes(property)) {
|
||||
if (describedAlternatives) {
|
||||
return [];
|
||||
}
|
||||
describedAlternatives = true;
|
||||
return [
|
||||
`<${requiredAlternatives.map(name => (name === property ? name : `${name}=...`)).join('|')}>`,
|
||||
];
|
||||
}
|
||||
return [required.has(property) ? `<${property}>` : `[${property}]`];
|
||||
});
|
||||
return [name, ...names].join(' ');
|
||||
}
|
||||
|
||||
export function buildHostActionParams(
|
||||
name: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
values: string[]
|
||||
): Record<string, unknown> {
|
||||
const properties = hostActionProperties(entry);
|
||||
const params: Record<string, unknown> = {};
|
||||
const positional: string[] = [];
|
||||
for (const value of values) {
|
||||
const named = NAMED_VALUE.exec(value);
|
||||
if (!named || !properties.has(named[1])) {
|
||||
positional.push(value);
|
||||
} else if (Object.hasOwn(params, named[1])) {
|
||||
throw new Error(`${named[1]} is given twice for ${name}.`);
|
||||
} else {
|
||||
params[named[1]] = coerce(named[2], properties.get(named[1]) ?? []);
|
||||
}
|
||||
}
|
||||
const open = positionalNames(entry).filter(property => !Object.hasOwn(params, property));
|
||||
if (positional.length > open.length) {
|
||||
throw new Error(
|
||||
`Too many values for ${name}. Usage: eas simulator:control ${formatHostActionUsage(name, entry)}. Name a value with name=value.`
|
||||
);
|
||||
}
|
||||
positional.forEach((value, index) => {
|
||||
params[open[index]] = coerce(value, properties.get(open[index]) ?? []);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function assertHostActionOutputAsync(
|
||||
name: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
outputPath: string | undefined
|
||||
): Promise<void> {
|
||||
if (!outputPath) {
|
||||
if (entry.output === 'base64') {
|
||||
throw new Error(`${name} returns a file. Pass --output with the path to save it to.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!entry.output) {
|
||||
throw new Error(`${name} does not return a file, so --output does not apply.`);
|
||||
}
|
||||
if ((await fs.stat(outputPath).catch(() => null))?.isDirectory()) {
|
||||
if (entry.output === 'base64') {
|
||||
throw new Error(`${outputPath} is a folder. Pass --output with a file path for ${name}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const folder = await fs.stat(path.dirname(outputPath)).catch(() => null);
|
||||
if (outputPath.endsWith('/') || outputPath.endsWith(path.sep) || !folder?.isDirectory()) {
|
||||
throw new Error(
|
||||
`The folder for ${outputPath} does not exist. Create it, or pass --output with a path in a folder that exists.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFileAsync(
|
||||
connection: HostActionConnection,
|
||||
localPath: string,
|
||||
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
|
||||
): Promise<{ uploadId: string; path: string }> {
|
||||
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
let remotePath = '';
|
||||
let first = true;
|
||||
try {
|
||||
for await (const chunk of fs.createReadStream(localPath, { highWaterMark: chunkBytes })) {
|
||||
const result = await connection.requestAsync('upload.append', {
|
||||
uploadId,
|
||||
data: Buffer.from(chunk).toString('base64'),
|
||||
first,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Could not upload ${localPath}: ${result.stderr.trim()}`);
|
||||
}
|
||||
remotePath = result.stdout.trim();
|
||||
first = false;
|
||||
}
|
||||
} catch (error) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (first) {
|
||||
throw new Error(`${localPath} is empty. Pass a file with content.`);
|
||||
}
|
||||
return { uploadId, path: remotePath };
|
||||
}
|
||||
|
||||
export async function saveHostActionOutputAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
stdout: string,
|
||||
outputPath: string | undefined
|
||||
): Promise<string | null> {
|
||||
if (!entry.output) {
|
||||
return null;
|
||||
}
|
||||
let base64 = stdout;
|
||||
let defaultName: string | undefined;
|
||||
if (entry.output === 'path') {
|
||||
const remotePath = stdout.trim();
|
||||
const file = await connection.requestAsync('file.readBase64', { path: remotePath });
|
||||
if (file.exitCode !== 0) {
|
||||
throw new Error(`Could not download ${remotePath}: ${file.stderr.trim()}`);
|
||||
}
|
||||
base64 = file.stdout;
|
||||
defaultName = path.basename(remotePath);
|
||||
}
|
||||
const outputIsFolder = outputPath
|
||||
? (await fs.stat(outputPath).catch(() => null))?.isDirectory()
|
||||
: false;
|
||||
const generatedName = outputIsFolder || !outputPath ? defaultName : undefined;
|
||||
const target = generatedName ? path.join(outputPath ?? '.', generatedName) : outputPath;
|
||||
if (!target) {
|
||||
throw new Error('Pass --output with a file path to save the file.');
|
||||
}
|
||||
try {
|
||||
await fs.writeFile(target, Buffer.from(base64, 'base64'), {
|
||||
flag: target === outputPath ? 'w' : 'wx',
|
||||
});
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
|
||||
throw new Error(`${target} already exists. Pass --output with a new path to save the file.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return path.resolve(target);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -142,6 +142,25 @@ export function getLocalEgressConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export type PreviewApiAccess = {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function getPreviewApiAccess(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): PreviewApiAccess | null {
|
||||
const token =
|
||||
remoteConfig.__typename === 'ServeSimRunSessionRemoteConfig' ||
|
||||
remoteConfig.__typename === 'WebPreviewOnlyRunSessionRemoteConfig'
|
||||
? remoteConfig.previewToken
|
||||
: remoteConfig.webPreviewToken;
|
||||
if (!remoteConfig.previewApiUrl || !token) {
|
||||
return null;
|
||||
}
|
||||
return { apiUrl: remoteConfig.previewApiUrl, token };
|
||||
}
|
||||
|
||||
/** The worker relay that the Android emulator's proxy points at; see build-tools androidLocalEgress.ts. */
|
||||
export const ANDROID_EMULATOR_EGRESS_RELAY_PORT = 8898;
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/build-tools": "25.3.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/logger
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "25.1.1",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/steps
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@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,13 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "25.1.1",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "25.1.1",
|
||||
"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.1.1",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/worker
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "25.1.1",
|
||||
"version": "25.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "25.1.1",
|
||||
"@expo/build-tools": "25.3.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@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.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@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(() => {
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user