mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34c0cb3f6 | ||
|
|
c7f766568c | ||
|
|
a328e59ee4 | ||
|
|
f843a97165 | ||
|
|
1e112fee24 | ||
|
|
6d3e79c255 | ||
|
|
04e909c4d9 | ||
|
|
1a6e94123a | ||
|
|
544cd18bac | ||
|
|
8dc514e9f2 | ||
|
|
f4b4f035b0 | ||
|
|
92397fd3d4 | ||
|
|
154d90d3ce | ||
|
|
5a0908533b |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Count Android web preview input as session activity, so `max_idle_time_minutes` works on Android sessions. `eas/start_serve_sim_remote_session` no longer rejects it on Android; the API does not accept an idle timeout for Android web-preview-only sessions yet.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
A successful remote device run session no longer fails when its agent-device, Appium, or Argent tunnel does not stop within its time-out.
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.10.0](https://github.com/expo/eas-cli/releases/tag/v27.10.0) - 2026-11-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas simulator:devices` to list the devices Expo suggests for a simulator session. ([#4879](https://github.com/expo/eas-cli/pull/4879) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Include the parent CLI command in telemetry to distinguish nested commands from direct usage. ([#4876](https://github.com/expo/eas-cli/pull/4876) by [@davidmokos](https://github.com/davidmokos))
|
||||
- [build-tools] Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli][build-tools] Bump Node.js to 22.23.5 to fix the CVEs in the [November 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/november-2026-security-releases). ([#4889](https://github.com/expo/eas-cli/pull/4889) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
## [27.9.0](https://github.com/expo/eas-cli/releases/tag/v27.9.0) - 2026-11-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.8.0",
|
||||
"PRODUCTION": "27.8.0"
|
||||
"STAGING": "27.10.0",
|
||||
"PRODUCTION": "27.10.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.4"
|
||||
node = "22.23.5"
|
||||
@@ -1,5 +1,18 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected].4 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected].5 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.9.0",
|
||||
"@expo/eas-build-job": "27.9.0",
|
||||
"@expo/downloader": "27.10.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.9.0",
|
||||
"@expo/logger": "27.10.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": "27.9.0",
|
||||
"@expo/template-file": "27.9.0",
|
||||
"@expo/turtle-spawn": "27.9.0",
|
||||
"@expo/steps": "27.10.0",
|
||||
"@expo/template-file": "27.10.0",
|
||||
"@expo/turtle-spawn": "27.10.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
+30
@@ -201,6 +201,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail a session when only the agent-device tunnel cannot stop', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(tunnelError);
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the agent-device tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const warmServer = { port: 3200 } as never;
|
||||
@@ -918,6 +933,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await expect(session).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('reports a startup Stop when only the agent-device tunnel cannot stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(new Error('tunnel stop timed out'));
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
|
||||
const retired = deferred();
|
||||
const retiring = jest.fn(() => retired.promise);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { installEmulatorCaptureAsync } from '../../utils/emulatorCapture';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -28,6 +29,11 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../utils/emulatorCapture', () => ({
|
||||
...jest.requireActual('../../utils/emulatorCapture'),
|
||||
installEmulatorCaptureAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -38,6 +44,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
findFreeConsolePortAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -54,7 +61,12 @@ function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
return Object.assign(step, { logger, globalCtx });
|
||||
}
|
||||
|
||||
/** The logger that the step function gets: a child of the base logger. */
|
||||
function stepLogger(step: ReturnType<typeof createStep>) {
|
||||
return jest.mocked(step.logger.child).mock.results[0].value;
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
@@ -470,4 +482,170 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('experimental emulator capture', () => {
|
||||
const LIBRARY = '/tmp/package/libexpo_emulator_capture.so';
|
||||
const mockedInstall = jest.mocked(installEmulatorCaptureAsync);
|
||||
|
||||
function captureStart(port: number) {
|
||||
return {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
consolePort: port,
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: {
|
||||
LD_PRELOAD: LIBRARY,
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: `/tmp/job/expo-emulator-capture/emulator-${port}.sock`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedInstall.mockReset().mockResolvedValue(LIBRARY);
|
||||
mockedAndroidUtils.findFreeConsolePortAsync
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(5554)
|
||||
.mockResolvedValueOnce(5556);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5554'));
|
||||
});
|
||||
|
||||
it('preloads the agent and turns the capture on for expo-device-hub', async () => {
|
||||
const step = createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedInstall).toHaveBeenCalledWith({
|
||||
version: '0.1.0',
|
||||
env: expect.any(Object),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(captureStart(5554));
|
||||
expect(step.globalCtx.env).toMatchObject({
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: LIBRARY,
|
||||
});
|
||||
expect(stepLogger(step).info).toHaveBeenCalledWith(
|
||||
'emulator-5554 preloads [email protected] with -gpu lavapipe, and its agent socket is /tmp/job/expo-emulator-capture/emulator-5554.sock. Later steps start expo-device-hub with the emulator capture on.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the temporary directory rule of Node when TMPDIR is empty', async () => {
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '', TMP: '/tmp/from-tmp' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/from-tmp/expo-emulator-capture/emulator-5554.sock',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses /tmp when the job env names no temporary directory', async () => {
|
||||
await createStep({ experimental_emulator_capture_version: '0.1.0' }).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/expo-emulator-capture/emulator-5554.sock',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn the capture on when the emulator runs on another console port', async () => {
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5560'));
|
||||
const step = createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
expect(stepLogger(step).warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('emulator-5560 does not use console port 5554')
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a library that the job already preloads, after the agent', async () => {
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job', LD_PRELOAD: '/opt/other.so' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({ LD_PRELOAD: `${LIBRARY}:/opt/other.so` }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('chooses a new console port for each start attempt', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockRejectedValueOnce(new Error('emulator did not start'))
|
||||
.mockResolvedValue(createStartResult('emulator-5556'));
|
||||
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(2, captureStart(5556));
|
||||
});
|
||||
|
||||
it('starts the emulator as without the input when the install fails', async () => {
|
||||
mockedInstall.mockResolvedValue(undefined);
|
||||
const step = createStep({ experimental_emulator_capture_version: '0.1.0' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
});
|
||||
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
// The session step still rejects session types without the capture.
|
||||
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('installs nothing without the input', async () => {
|
||||
const step = createStep();
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects the capture for more than one emulator before it starts one', async () => {
|
||||
await expect(
|
||||
createStep({ experimental_emulator_capture_version: '0.1.0', count: 2 }).executeAsync()
|
||||
).rejects.toThrow('experimental_emulator_capture_version supports one emulator');
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a version that is not an exact npm version or a dist-tag', async () => {
|
||||
await expect(
|
||||
createStep({ experimental_emulator_capture_version: 'npm:[email protected]' }).executeAsync()
|
||||
).rejects.toThrow('must be an npm version or dist-tag of expo-emulator-capture');
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -850,6 +850,26 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('does not fail a session when only the Appium tunnel cannot stop', async () => {
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
stopAppium.mockResolvedValueOnce(undefined);
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValueOnce({
|
||||
url: 'https://appium-abc.tunnel.example.com',
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn().mockRejectedValue(tunnelError),
|
||||
});
|
||||
|
||||
await expect(runSessionAsync(logger)).resolves.toBeUndefined();
|
||||
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allocate Appium or a host after parent cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
|
||||
+28
-29
@@ -667,7 +667,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
['succeeded', false],
|
||||
['failed', true],
|
||||
])(
|
||||
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
|
||||
'stops the host and tools when the tools tunnel cannot close and the session %s',
|
||||
async (_description, sessionFails) => {
|
||||
const closeError = new Error('ngrok close failed');
|
||||
const sessionError = new Error('session failed');
|
||||
@@ -677,23 +677,26 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(sessionFails ? sessionError : closeError);
|
||||
// A stopped tunnel's proxy drops every connection, so its failure does not fail a session.
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
if (sessionFails) {
|
||||
await expect(running).rejects.toBe(sessionError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -702,17 +705,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
{ err: closeError },
|
||||
'Could not stop the Argent tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
|
||||
sessionFails
|
||||
? [
|
||||
[
|
||||
'Could not stop the Argent tunnel after the remote session failed',
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
|
||||
[
|
||||
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+22
-20
@@ -238,18 +238,29 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
it('lets the session host use the experimental emulator capture', async () => {
|
||||
await runAsync(BuildRuntimePlatform.LINUX);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ emulatorCapture: true })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'uses preview activity to reset the configured idle timeout on %s',
|
||||
async runtimePlatform => {
|
||||
await runAsync(runtimePlatform, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'leaves the idle timer disabled when omitted on %s',
|
||||
async runtimePlatform => {
|
||||
@@ -269,16 +280,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an Android idle timeout before starting the host', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
})
|
||||
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'handles Stop while the %s host starts',
|
||||
async runtimePlatform => {
|
||||
@@ -508,6 +509,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
}
|
||||
: {}),
|
||||
packageVersion: '1.2.3',
|
||||
emulatorCapture: true,
|
||||
disableShare: false,
|
||||
frameAncestors: [],
|
||||
corsOrigins: [],
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -213,6 +214,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
@@ -351,7 +353,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? [['startup cleanup', Promise.reject(result.reason)]]
|
||||
: []
|
||||
),
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['agent-device daemon', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -25,6 +25,11 @@ import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import {
|
||||
EMULATOR_CAPTURE_REQUESTED_ENV,
|
||||
installEmulatorCaptureAsync,
|
||||
parseExperimentalEmulatorCaptureVersionInput,
|
||||
} from '../utils/emulatorCapture';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
@@ -81,6 +86,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: 1,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Experimental: the expo-emulator-capture version that the emulator preloads.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'experimental_emulator_capture_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -89,10 +100,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
const emulatorCaptureVersion = parseExperimentalEmulatorCaptureVersionInput(
|
||||
inputs.experimental_emulator_capture_version.value
|
||||
);
|
||||
if (emulatorCaptureVersion && count > 1) {
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_INVALID_INPUT',
|
||||
'experimental_emulator_capture_version supports one emulator. Remove it, or set count to 1.'
|
||||
);
|
||||
}
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
});
|
||||
if (emulatorCaptureVersion) {
|
||||
// Marks the request for the session step, which rejects session types that cannot use
|
||||
// the capture. The mark does not depend on the install, so the rejection does not either.
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
[EMULATOR_CAPTURE_REQUESTED_ENV]: emulatorCaptureVersion,
|
||||
});
|
||||
}
|
||||
const emulatorCaptureLibrary = emulatorCaptureVersion
|
||||
? await installEmulatorCaptureAsync({ version: emulatorCaptureVersion, env, logger })
|
||||
: undefined;
|
||||
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
@@ -100,7 +133,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale, emulatorCaptureSocketPath } =
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
@@ -113,9 +146,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
emulatorCaptureLibrary,
|
||||
});
|
||||
if (emulatorCaptureLibrary && emulatorCaptureSocketPath) {
|
||||
// The session step starts expo-device-hub with the job env. A Hub version without the
|
||||
// emulator capture ignores these variables.
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: emulatorCaptureLibrary,
|
||||
});
|
||||
logger.info(
|
||||
`${serialId} preloads expo-emulator-capture@${emulatorCaptureVersion} with -gpu lavapipe, and its agent socket is ${emulatorCaptureSocketPath}. Later steps start expo-device-hub with the emulator capture on.`
|
||||
);
|
||||
}
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
logger.info(`Requested ${count} emulators, shutting down ${deviceName} for cloning.`);
|
||||
await spawn('adb', ['-s', serialId, 'shell', 'reboot', '-p'], {
|
||||
@@ -222,6 +267,7 @@ export async function startAndroidEmulatorAsync({
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
emulatorCaptureLibrary,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
@@ -233,10 +279,14 @@ export async function startAndroidEmulatorAsync({
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
/** The expo-emulator-capture agent library that the emulator preloads (experimental). */
|
||||
emulatorCaptureLibrary?: string;
|
||||
}): Promise<{
|
||||
serialId: AndroidDeviceSerialId;
|
||||
emulatorPromise: Promise<unknown>;
|
||||
shouldAdjustAnimationScale: boolean;
|
||||
/** The capture agent's socket, when the emulator preloads the agent on the expected serial. */
|
||||
emulatorCaptureSocketPath?: string;
|
||||
}> {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
@@ -284,8 +334,16 @@ export async function startAndroidEmulatorAsync({
|
||||
}
|
||||
);
|
||||
|
||||
// expo-device-hub gets the job env and finds the agent sockets in `<tmpdir>/expo-emulator-capture`,
|
||||
// where Node's os.tmpdir() applies this rule to that env. On EAS, TMPDIR is not set.
|
||||
const captureSocketDirectory = path.join(
|
||||
env.TMPDIR || env.TMP || env.TEMP || '/tmp',
|
||||
'expo-emulator-capture'
|
||||
);
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
// Set by the attempt that starts; the cast keeps the type wide after the retry callback.
|
||||
let captureLaunch = undefined as { consolePort: number; socketPath: string } | undefined;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
@@ -308,12 +366,37 @@ export async function startAndroidEmulatorAsync({
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
// expo-device-hub finds the capture agent's socket by the serial, so the console port,
|
||||
// which sets the serial, is chosen before the start, again for each attempt.
|
||||
const consolePort = emulatorCaptureLibrary
|
||||
? await AndroidEmulatorUtils.findFreeConsolePortAsync()
|
||||
: undefined;
|
||||
const launch =
|
||||
consolePort !== undefined
|
||||
? {
|
||||
consolePort,
|
||||
socketPath: path.join(captureSocketDirectory, `emulator-${consolePort}.sock`),
|
||||
}
|
||||
: undefined;
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
...(emulatorCaptureLibrary && launch
|
||||
? {
|
||||
consolePort: launch.consolePort,
|
||||
// The Linux agent kept 60 fps with the lavapipe renderer.
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: {
|
||||
// A library that the job already preloads stays preloaded, after the agent.
|
||||
LD_PRELOAD: [emulatorCaptureLibrary, env.LD_PRELOAD].filter(Boolean).join(':'),
|
||||
// The agent creates the directory.
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: launch.socketPath,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
@@ -335,6 +418,7 @@ export async function startAndroidEmulatorAsync({
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
captureLaunch = launch;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
|
||||
try {
|
||||
@@ -368,7 +452,19 @@ export async function startAndroidEmulatorAsync({
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
|
||||
// The Hub looks for the socket by serial. $ANDROID_EMULATOR_EXTRA_ARGS can override -port.
|
||||
if (captureLaunch && serialId !== `emulator-${captureLaunch.consolePort}`) {
|
||||
logger.warn(
|
||||
`${serialId} does not use console port ${captureLaunch.consolePort}, so expo-device-hub cannot find its capture agent socket ${captureLaunch.socketPath}. The session streams without the emulator capture. Remove -port from $ANDROID_EMULATOR_EXTRA_ARGS.`
|
||||
);
|
||||
captureLaunch = undefined;
|
||||
}
|
||||
return {
|
||||
serialId,
|
||||
emulatorPromise,
|
||||
shouldAdjustAnimationScale,
|
||||
emulatorCaptureSocketPath: captureLaunch?.socketPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
|
||||
@@ -52,6 +52,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -328,6 +329,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
|
||||
// The preview host uses the same device selected for Appium.
|
||||
@@ -415,7 +417,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', appiumTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../u
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -386,6 +387,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
@@ -481,7 +483,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Argent tool-server', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -170,6 +170,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -87,16 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (
|
||||
maxIdleTimeMinutes !== undefined &&
|
||||
maxIdleTimeMinutes > 0 &&
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN
|
||||
) {
|
||||
throw new UserError(
|
||||
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
@@ -152,6 +141,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion: resolvedPackageVersion,
|
||||
emulatorCapture: true,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { startPreviewActivityPolling } from '../previewActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import * as previewCapture from '../serveSimPreviewCapture';
|
||||
|
||||
@@ -59,7 +59,7 @@ jest.mock('../serveSimActions');
|
||||
jest.mock('../iosAppArtifact', () => ({
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../previewActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
@@ -644,7 +644,7 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
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({
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
@@ -661,7 +661,8 @@ it('reports preview activity from serve-sim on iOS and stops polling when finish
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
@@ -688,15 +689,33 @@ it('does not start polling preview activity when startup is cancelled', async ()
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(startPreviewActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
it('reports preview activity from expo-device-hub on Android and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-11-13T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token: expect.any(String),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
@@ -856,6 +875,73 @@ it('drains an in-flight tunnel when finishing and rejects new opens', async () =
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a rejected preview tunnel open after finishing', async () => {
|
||||
const host = await startHostAsync();
|
||||
await (await host.openPreviewAsync()).closeAsync();
|
||||
const forwardCalls = jest.mocked(ngrok.forward).mock.calls.length;
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync();
|
||||
const finishing = host.finishAsync();
|
||||
tunnel.reject(
|
||||
new Error('failed to start listener: internal server error error_code: ERR_NGROK_802')
|
||||
);
|
||||
await expect(opening).rejects.toThrow('ERR_NGROK_802');
|
||||
await finishing;
|
||||
expect(jest.mocked(ngrok.forward).mock.calls.length).toBe(forwardCalls + 1);
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('; retrying.')
|
||||
);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a rejected preview tunnel open after startup fails', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
const forwardCalls = jest.mocked(ngrok.forward).mock.calls.length;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockRejectedValueOnce(
|
||||
new Error('failed to start listener: internal server error error_code: ERR_NGROK_802')
|
||||
);
|
||||
const startGate = deferred<void>();
|
||||
const startupError = new Error('startup failure');
|
||||
const startAsync = jest.fn(async () => {
|
||||
await startGate.promise;
|
||||
throw startupError;
|
||||
});
|
||||
const outcome = startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
preview: { baseDomain, authtoken },
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
port: 3200,
|
||||
turnArgs: Promise.resolve([]),
|
||||
requiresPreviewRetirement: true,
|
||||
startAsync,
|
||||
stopAsync: stopServer,
|
||||
}
|
||||
).catch(error => error);
|
||||
const retrying = () =>
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.some(([, message]) => String(message).endsWith('; retrying.'));
|
||||
for (let i = 0; i < 50 && !retrying(); i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(retrying()).toBe(true);
|
||||
startGate.resolve();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
expect(await outcome).toBe(startupError);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls.length).toBe(forwardCalls + 1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
@@ -1127,6 +1213,55 @@ it.each([
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('keeps the experimental emulator capture off in expo-device-hub by default', async () => {
|
||||
const host = await startHostAsync();
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBe('0');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('rejects an emulator capture request in a session type without the capture', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { ...env, EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION: '0.1.0' },
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The experimental emulator capture ([email protected]) works only in web preview sessions for now.'
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the emulator capture that the emulator start turned on to expo-device-hub', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: '/tmp/package/libexpo_emulator_capture.so',
|
||||
EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION: '0.1.0',
|
||||
},
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
emulatorCapture: true,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.env).toMatchObject({
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: '/tmp/package/libexpo_emulator_capture.so',
|
||||
});
|
||||
// The env turns the capture on, because an expo-device-hub without it rejects unknown flags.
|
||||
expect(options.args).not.toContain('--experimental-emulator-capture');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the turtle-v2 copy by default', undefined, 'https://storage.googleapis.com/turtle-v2/scrcpy'],
|
||||
['the step env value when it is set', 'https://mirror.test/scrcpy', 'https://mirror.test/scrcpy'],
|
||||
@@ -1194,6 +1329,7 @@ it('leaves iOS recording to its existing build steps', async () => {
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.args).not.toContain('--android-recording-directory');
|
||||
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
|
||||
expect(options.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { installIsolatedNpmPackageAsync } from '../../../utils/installIsolatedNpmPackageAsync';
|
||||
import {
|
||||
installEmulatorCaptureAsync,
|
||||
parseExperimentalEmulatorCaptureVersionInput,
|
||||
} from '../emulatorCapture';
|
||||
|
||||
jest.mock('../../../utils/installIsolatedNpmPackageAsync', () => ({
|
||||
installIsolatedNpmPackageAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEMPORARY_DIRECTORY = '/tmp/eas-test';
|
||||
const AGENT = 'node_modules/expo-emulator-capture/dist/linux-x64/libexpo_emulator_capture.so';
|
||||
const mockedInstall = jest.mocked(installIsolatedNpmPackageAsync);
|
||||
const { platform, arch } = process;
|
||||
|
||||
function setHost(hostPlatform: NodeJS.Platform, hostArch: string): void {
|
||||
Object.defineProperty(process, 'platform', { value: hostPlatform });
|
||||
Object.defineProperty(process, 'arch', { value: hostArch });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(os, 'tmpdir').mockReturnValue(TEMPORARY_DIRECTORY);
|
||||
fs.mkdirSync(TEMPORARY_DIRECTORY, { recursive: true });
|
||||
mockedInstall.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
setHost(platform, arch);
|
||||
});
|
||||
|
||||
describe(parseExperimentalEmulatorCaptureVersionInput, () => {
|
||||
it.each([undefined, null, ''])('turns the capture off for %p', value => {
|
||||
expect(parseExperimentalEmulatorCaptureVersionInput(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['0.1.0', '0.1.0-canary-20261106-2b670aa', 'canary', 'latest'])('accepts %s', value => {
|
||||
expect(parseExperimentalEmulatorCaptureVersionInput(value)).toBe(value);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'^0.1.0',
|
||||
'../0.1.0',
|
||||
'npm:[email protected]',
|
||||
'file:/tmp/agent',
|
||||
'0.1.0 ',
|
||||
'-0.1.0',
|
||||
'x'.repeat(129),
|
||||
1,
|
||||
])('rejects %p', value => {
|
||||
expect(() => parseExperimentalEmulatorCaptureVersionInput(value)).toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(installEmulatorCaptureAsync, () => {
|
||||
const env = { PATH: '/usr/bin', EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.example' };
|
||||
|
||||
it('installs the package in its own directory and returns its agent library', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockImplementation(async ({ directory }) => {
|
||||
fs.mkdirSync(path.dirname(path.join(directory, AGENT)), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, AGENT), '');
|
||||
});
|
||||
const logger = createMockLogger();
|
||||
|
||||
const library = await installEmulatorCaptureAsync({ version: '0.1.0', env, logger });
|
||||
|
||||
expect(mockedInstall).toHaveBeenCalledWith({
|
||||
packageSpec: '[email protected]',
|
||||
directory: expect.stringMatching(/^\/tmp\/eas-test\/eas-emulator-capture-package-/),
|
||||
npmCacheUrl: 'https://npm-cache.example',
|
||||
pathEnv: '/usr/bin',
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(library).toBe(path.join(mockedInstall.mock.calls[0][0].directory, AGENT));
|
||||
});
|
||||
|
||||
it('returns undefined when the install fails', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockRejectedValue(new Error('npm error 404'));
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: '0.1.0', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not install [email protected]')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the package has no Linux agent library', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockResolvedValue(undefined);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: 'latest', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('expo-emulator-capture@latest has no Linux x86-64 agent at')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined on a host that is not Linux x86-64', async () => {
|
||||
setHost('darwin', 'arm64');
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: '0.1.0', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Linux x86-64'));
|
||||
});
|
||||
});
|
||||
@@ -563,26 +563,276 @@ it('does not queue more native opens while a timed-out open is still pending', a
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds initial tunnel creation and retires a late listener', async () => {
|
||||
const ERR_802 = 'failed to start listener: internal server error error_code: ERR_NGROK_802';
|
||||
|
||||
it('bounds initial tunnel creation, does not retry a timed-out open, and retires a late listener', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out after 19000ms');
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
// ngrok runs opens one at a time, so a retry would only wait behind the stuck open.
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('); retrying.')
|
||||
);
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: 'opened', elapsedMs: 19_000 }),
|
||||
'ngrok tunnel open for web-preview-fixed.example.test opened after 19000 ms, after it timed out or was cancelled.'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a rejected close after initial tunnel creation timed out', async () => {
|
||||
it('keeps a first open that settles after the reopen time-out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(16_000);
|
||||
resolveOpening(initial as never);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails without a retry when the first open fails for another reason after 15 seconds', async () => {
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise((_, reject) => (rejectOpening = reject)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const error = new Error('invalid authtoken');
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toBe(error);
|
||||
await jest.advanceTimersByTimeAsync(16_000);
|
||||
rejectOpening(error);
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a timed-out open that ngrok rejects later', async () => {
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise((_, reject) => (rejectOpening = reject)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
rejectOpening(new Error(ERR_802));
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an initial open that ngrok rejects on a new agent session', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockResolvedValue(initial as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[0][0]).not.toHaveProperty('force_new_session');
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toMatchObject({ force_new_session: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attempt: 1 }),
|
||||
'Could not open ngrok tunnel web-preview-fixed.example.test (attempt 1 of 3); retrying.'
|
||||
);
|
||||
expect(tunnel.url).toBe(url);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries an initial open whose error carries the ngrok error code', async () => {
|
||||
const error = Object.assign(new Error('failed to start listener'), {
|
||||
errorCode: 'ERR_NGROK_802',
|
||||
});
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue(initial as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a retry the reopen time-out', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out after 15000ms');
|
||||
await jest.advanceTimersByTimeAsync(1_000 + 15_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails after three rejected initial opens', async () => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error(ERR_802));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('ERR_NGROK_802');
|
||||
await jest.advanceTimersByTimeAsync(1_000 + 2_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[2][0]).toMatchObject({ force_new_session: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another reason', 'invalid authtoken'],
|
||||
['a longer error code', 'internal server error error_code: ERR_NGROK_8021'],
|
||||
])('does not retry an initial open that fails for %s', async (_, message) => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error(message));
|
||||
await expect(startNgrokTunnelAsync(options)).rejects.toThrow(message);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails with the abort reason when the caller aborts during the retry backoff', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockResolvedValue(initial as never);
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
const rejected = expect(opening).rejects.toBe(reason);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
startup.abort(reason);
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open when the caller aborts while the local target starts', async () => {
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
startup.abort(reason);
|
||||
await expect(opening).rejects.toBe(reason);
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
'fails with the abort reason and retires the late listener when the caller aborts while open %s is pending',
|
||||
async pendingAttempt => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
const pending = new Promise<ngrok.Listener>(resolve => (resolveOpening = resolve));
|
||||
const forward = jest.mocked(ngrok.forward).mockReset();
|
||||
if (pendingAttempt === 2) {
|
||||
forward.mockRejectedValueOnce(new Error(ERR_802));
|
||||
}
|
||||
forward.mockReturnValueOnce(pending as never).mockResolvedValue(replacement as never);
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
const rejected = expect(opening).rejects.toBe(reason);
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(pendingAttempt);
|
||||
startup.abort(reason);
|
||||
await rejected;
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(pendingAttempt);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops retrying without a new open when the retry signal aborts during the backoff', async () => {
|
||||
const error = new Error(ERR_802);
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue(initial as never);
|
||||
const retries = new AbortController();
|
||||
const opening = startNgrokTunnelAsync({ ...options, retrySignal: retries.signal });
|
||||
const rejected = expect(opening).rejects.toBe(error);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
retries.abort();
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets the open in progress finish when the retry signal aborts', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const retries = new AbortController();
|
||||
const opening = startNgrokTunnelAsync({ ...options, retrySignal: retries.signal });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
retries.abort();
|
||||
resolveOpening(initial as never);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops listening for the startup signal after startup fails', async () => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('invalid authtoken'));
|
||||
const startup = new AbortController();
|
||||
const removeEventListener = jest.spyOn(startup.signal, 'removeEventListener');
|
||||
await expect(startNgrokTunnelAsync({ ...options, signal: startup.signal })).rejects.toThrow(
|
||||
'invalid authtoken'
|
||||
);
|
||||
expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function));
|
||||
});
|
||||
|
||||
it('keeps the tunnel open when the startup signal aborts after startup', async () => {
|
||||
const startup = new AbortController();
|
||||
const tunnel = await startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
try {
|
||||
startup.abort();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close of a late listener after initial tunnel creation timed out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
@@ -590,7 +840,7 @@ it('retries a rejected close after initial tunnel creation timed out', async ()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(initial as never);
|
||||
@@ -816,8 +1066,10 @@ it.each(['resolve', 'reject'])(
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('open timed out');
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
// The first open times out, and startup does not retry it while it is still pending.
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
if (outcome === 'resolve') {
|
||||
resolveOpening(replacement as never);
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import {
|
||||
readExpoDeviceHubInputLogAsync,
|
||||
readServeSimInputLogAsync,
|
||||
startPreviewActivityPolling,
|
||||
} from '../previewActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number; at?: string; timestamp?: string }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
let replaying: boolean;
|
||||
let replayStartedAt: string | null;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
replaying = false;
|
||||
replayStartedAt = null;
|
||||
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), replaying, replayStartedAt }));
|
||||
});
|
||||
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(readServeSimInputLogAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 7,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
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(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
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(readServeSimInputLogAsync({ port, token })).rejects.toThrow('serve-sim event log');
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(readExpoDeviceHubInputLogAsync, () => {
|
||||
it('reads the newest entry time from either server', async () => {
|
||||
events = [{ id: 2, timestamp: '2026-11-13T12:00:02.000Z' }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 2,
|
||||
latestEventAt: '2026-11-13T12:00:02.000Z',
|
||||
});
|
||||
events = [{ id: 3, at: '2026-11-13T12:00:03.000Z' }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 3,
|
||||
latestEventAt: '2026-11-13T12:00:03.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the newest recorded Android input through the hub with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 9 }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 9,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
{ url: '/vendor/serve-emu/api/session?limit=1', authorization: `Bearer ${token}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a running replay and when it started', async () => {
|
||||
replaying = true;
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads replay state from the paged response shape', async () => {
|
||||
rawBody = JSON.stringify({
|
||||
events: [{ id: 4, at: '2026-11-13T12:00:01.000Z' }],
|
||||
session: { replaying: true, replayStartedAt: '2026-11-13T12:00:00.000Z' },
|
||||
});
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 4,
|
||||
latestEventAt: '2026-11-13T12:00:01.000Z',
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no event ID when the hub has recorded no input', async () => {
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"9"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).rejects.toThrow(
|
||||
'expo-device-hub input log'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startPreviewActivityPolling, () => {
|
||||
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 = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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 = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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 = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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 = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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 = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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('reports Android preview input from expo-device-hub', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
expect(requests[0].url).toBe('/vendor/serve-emu/api/session?limit=1');
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports activity while expo-device-hub replays recorded input', async () => {
|
||||
replaying = true;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a replay that started and finished between two reads', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const replayObservedAt = polling.getLastActivityObservedAt();
|
||||
expect(replayObservedAt).not.toBe(observedAt);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(replayObservedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording that repeats the last ID between two reads', async () => {
|
||||
events = [{ id: 1, at: '2026-11-13T12:00:00.000Z' }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1, at: '2026-11-13T12:05:00.000Z' }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording whose IDs start again', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
|
||||
events = [];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
events = [{ id: 1 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['before its headers', 'headers'],
|
||||
['after its headers', 'body'],
|
||||
] as const)(
|
||||
'reports current activity while an expo-device-hub read times out %s',
|
||||
async (_, stall) => {
|
||||
events = [{ id: 1 }];
|
||||
let stalled = false;
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
if (stalled && stall === 'headers') {
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
if (stalled) {
|
||||
res.write('{"events":[');
|
||||
return;
|
||||
}
|
||||
res.end(JSON.stringify({ events, replaying: false, replayStartedAt: null }));
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
requestTimeoutMs: 100,
|
||||
});
|
||||
try {
|
||||
while (polling.getLastActivityObservedAt() === undefined) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
|
||||
stalled = true;
|
||||
// The next request starts only after the stalled read times out.
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const reportedAt = polling.getLastActivityObservedAt();
|
||||
expect(reportedAt).not.toBe(observedAt);
|
||||
expect(jest.mocked(logger.warn).mock.calls[0][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
server.closeAllConnections();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('warns about an unreadable log after an earlier device outage', async () => {
|
||||
const logger = createLoggerMock();
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
status = 500;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(logger.warn).mock.calls[1][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while expo-device-hub has no device', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
expect(observedAt).toBeDefined();
|
||||
|
||||
status = 503;
|
||||
await waitForRequestsAsync(requests.length + 3);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim answers 503', async () => {
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
finishRemoteSessionAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
@@ -226,6 +228,89 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(finishRemoteSessionAsync, () => {
|
||||
const tunnelError = new Error('tunnel stop timed out');
|
||||
const serverError = new Error('server stop failed');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
});
|
||||
|
||||
it('does not fail a successful session when only a tunnel cannot stop', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.resolve()],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails a successful session with the first failure that fails the session', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
],
|
||||
})
|
||||
).rejects.toBe(serverError);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports every teardown failure to Sentry when the session failed', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: true,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
['session host', undefined],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session failed',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
[
|
||||
'Could not stop the Appium server after the remote session failed',
|
||||
serverError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createIosSessionStartupInputProviders, () => {
|
||||
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
|
||||
const inputs = createIosSessionStartupInputProviders().map(provider =>
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from './remoteDeviceRunSession';
|
||||
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { EMULATOR_CAPTURE_REQUESTED_ENV } from './emulatorCapture';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { type PreviewActivityPolling, startPreviewActivityPolling } from './previewActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createColdIosSessionAsync } from './coldIosSession';
|
||||
@@ -375,7 +376,7 @@ export type DeviceWebPreview = {
|
||||
export type DeviceSessionHost = {
|
||||
/** The preview tunnel opened during startup, or a new one after closeAsync. */
|
||||
openPreviewAsync(): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
/** Last observed preview input from the serve-sim (iOS) or expo-device-hub (Android) input log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
|
||||
@@ -417,6 +418,11 @@ export type DeviceSessionHostOptions = {
|
||||
onStartupError?: (error: unknown) => void;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
/**
|
||||
* This session type runs an expo-device-hub that can use the experimental emulator capture.
|
||||
* Only web preview sets it; the host rejects a capture request from any other session type.
|
||||
*/
|
||||
emulatorCapture?: boolean;
|
||||
} & ServeSimLaunchOptions;
|
||||
|
||||
export type DeviceSessionServerLaunch = {
|
||||
@@ -530,6 +536,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
networkCaptureFields = [],
|
||||
bootEnv = {},
|
||||
preview,
|
||||
emulatorCapture = false,
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan,
|
||||
@@ -537,6 +544,15 @@ async function startDeviceSessionHostInternalAsync(
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
const requestedEmulatorCapture = env[EMULATOR_CAPTURE_REQUESTED_ENV];
|
||||
if (isAndroid && requestedEmulatorCapture && !emulatorCapture) {
|
||||
// Other session types run the latest expo-device-hub, which ignores the capture, so the
|
||||
// session would start without the capture that it asked for.
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_UNSUPPORTED_SESSION',
|
||||
`The experimental emulator capture (expo-emulator-capture@${requestedEmulatorCapture}) works only in web preview sessions for now. Remove experimental_emulator_capture_version from eas/start_android_emulator, or start a web preview session.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(bootEnv).length > 0 &&
|
||||
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
|
||||
@@ -608,6 +624,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
const previewRetirementErrors: unknown[] = [];
|
||||
// Stops new attempts to open the preview tunnel when the host finishes. An attempt in progress
|
||||
// continues, so preview retirement still waits for it and closes its listener.
|
||||
const previewRetryController = new AbortController();
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let iosPreviewCapture: ReturnType<typeof createServeSimPreviewCapture> | undefined;
|
||||
@@ -615,7 +634,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
let previewActivity: PreviewActivityPolling | null = null;
|
||||
|
||||
function openPreviewAsync(): Promise<DeviceWebPreview> {
|
||||
if (finishTask) {
|
||||
@@ -635,6 +654,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
authtoken: preview.authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
retrySignal: previewRetryController.signal,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
@@ -772,6 +792,12 @@ async function startDeviceSessionHostInternalAsync(
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
// The emulator start turns the experimental emulator capture on in the job env. Otherwise
|
||||
// keep it off, so a Hub build with the capture on by default does not try to attach the
|
||||
// agent with a debugger.
|
||||
...(isAndroid && env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE === undefined
|
||||
? { EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '0' }
|
||||
: {}),
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
@@ -799,6 +825,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
startup.abort(error);
|
||||
// Mark the opening tunnel as retired; its late-open path closes it.
|
||||
finishTask = Promise.resolve();
|
||||
previewRetryController.abort();
|
||||
await Promise.allSettled([simulatorSetup, retirePreviewAsync()]);
|
||||
let hostStopped = true;
|
||||
try {
|
||||
@@ -826,6 +853,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= (async () => {
|
||||
previewRetryController.abort();
|
||||
const previewRetirement = retirePreviewAsync();
|
||||
if (server.requiresPreviewRetirement) {
|
||||
// Confirm the tunnel is closed before stop, so the next session cannot reach this one.
|
||||
@@ -997,9 +1025,14 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
if (!finishTask) {
|
||||
previewActivity = startPreviewActivityPolling({
|
||||
server: isAndroid ? 'expo-device-hub' : 'serve-sim',
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
|
||||
|
||||
// Experimental emulator capture. The Android Emulator preloads the expo-emulator-capture agent,
|
||||
// which serves the emulator screen as H.264 on a Unix socket, and expo-device-hub streams the
|
||||
// screen from that socket. The step input names the agent version, so a new agent release needs
|
||||
// no change in build-tools or in the backend, while the package keeps its library at this path.
|
||||
|
||||
const PACKAGE_NAME = 'expo-emulator-capture';
|
||||
/**
|
||||
* `eas/start_android_emulator` sets this job env variable to the requested agent version. The
|
||||
* session host rejects it in every session type except web preview, the only one that runs an
|
||||
* expo-device-hub version that can use the capture for now.
|
||||
*/
|
||||
export const EMULATOR_CAPTURE_REQUESTED_ENV = 'EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION';
|
||||
const LINUX_AGENT_LIBRARY = path.join(
|
||||
'node_modules',
|
||||
PACKAGE_NAME,
|
||||
'dist',
|
||||
'linux-x64',
|
||||
'libexpo_emulator_capture.so'
|
||||
);
|
||||
/**
|
||||
* An npm version, a simple range such as `0.1.x`, or a dist-tag: letters, digits, `.`, `_` and `-`.
|
||||
* It keeps out specs that install another package, such as `npm:`, `file:` or git URLs. eas-cli
|
||||
* and www check the same pattern.
|
||||
*/
|
||||
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/;
|
||||
/** The registry normally answers in seconds; a stalled install must not hold the emulator. */
|
||||
const INSTALL_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
/** The expo-emulator-capture version to preload, or undefined when the capture is off. */
|
||||
export function parseExperimentalEmulatorCaptureVersionInput(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) {
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_INVALID_VERSION',
|
||||
`experimental_emulator_capture_version must be an npm version or dist-tag of ${PACKAGE_NAME}, made of letters, digits, ".", "_" and "-".`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the agent package and returns the path of its library. Returns undefined after a
|
||||
* warning when the agent cannot run on this host or the install fails. The emulator then starts
|
||||
* as it does without the capture.
|
||||
*/
|
||||
export async function installEmulatorCaptureAsync({
|
||||
version,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
version: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<string | undefined> {
|
||||
if (process.platform !== 'linux' || process.arch !== 'x64') {
|
||||
logger.warn(
|
||||
`The experimental emulator capture runs only on Linux x86-64 hosts. The emulator starts without ${PACKAGE_NAME}.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
let directory: string;
|
||||
try {
|
||||
directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-emulator-capture-package-'));
|
||||
logger.info(`Installing ${PACKAGE_NAME}@${version} for the experimental emulator capture.`);
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec: `${PACKAGE_NAME}@${version}`,
|
||||
directory,
|
||||
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
|
||||
pathEnv: env.PATH,
|
||||
logger,
|
||||
signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not install ${PACKAGE_NAME}@${version}. The emulator starts without it.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const library = path.join(directory, LINUX_AGENT_LIBRARY);
|
||||
if (!fs.existsSync(library)) {
|
||||
logger.warn(
|
||||
`${PACKAGE_NAME}@${version} has no Linux x86-64 agent at ${library}. The emulator starts without it.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return library;
|
||||
}
|
||||
@@ -12,10 +12,23 @@ import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
const PROBE_INTERVAL_MS = 15_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
const STOP_TIMEOUT_MS = 4_000;
|
||||
const OPEN_TIMEOUT_MS = 15_000;
|
||||
// @ngrok/ngrok runs one open at a time, so nothing can open again until a timed-out open
|
||||
// settles. The first open waits longer instead, and keeps a listener that arrives late. Retries
|
||||
// use OPEN_TIMEOUT_MS, so all attempts fit in the 60-second preview opening bound.
|
||||
const FIRST_OPEN_TIMEOUT_MS = 19_000;
|
||||
const INITIAL_OPEN_ATTEMPTS = 3;
|
||||
const INITIAL_OPEN_RETRY_DELAY_MS = 1_000;
|
||||
/** ngrok error codes for failed control-plane requests that can succeed on a retry. */
|
||||
const RETRYABLE_NGROK_ERROR_CODES = new Set(['ERR_NGROK_802']);
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
/**
|
||||
* Stops the tunnel. The local proxy drops every connection at once; then the listeners close.
|
||||
* Rejects when the stop does not complete within the stop time-out.
|
||||
*/
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +41,8 @@ export async function startNgrokTunnelAsync({
|
||||
rewriteHostHeader,
|
||||
healthCheck,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
retrySignal,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
@@ -37,11 +52,21 @@ export async function startNgrokTunnelAsync({
|
||||
rewriteHostHeader?: boolean;
|
||||
healthCheck?: { path: string; statuses?: number[] };
|
||||
logger: bunyan;
|
||||
/** Cancels startup, including retries of the first open. It has no effect after startup. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Stops new attempts of the first open without cancelling the attempt in progress, so the
|
||||
* caller can still wait for that attempt and close its listener. The first attempt always starts.
|
||||
*/
|
||||
retrySignal?: AbortSignal;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
startupSignal?.throwIfAborted();
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
// After an abort, this proxy drops every connection, even while an ngrok listener is still open.
|
||||
// Session teardown relies on this (TOOL_TUNNEL_TEARDOWN_OPTIONS), so keep it when changing this.
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
@@ -154,10 +179,18 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
|
||||
/**
|
||||
* No open is queued behind a pending one: @ngrok/ngrok runs every forward behind one
|
||||
* process-wide lock, so a new open cannot start until the pending one settles.
|
||||
*/
|
||||
async function openAsync({
|
||||
forceNewSession = false,
|
||||
timeoutMs = OPEN_TIMEOUT_MS,
|
||||
}: { forceNewSession?: boolean; timeoutMs?: number } = {}): Promise<ngrok.Listener> {
|
||||
if (pendingOpen) {
|
||||
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let acquired: ngrok.Listener | undefined;
|
||||
let retirement: Promise<void> | undefined;
|
||||
const retireAcquired = () => {
|
||||
@@ -167,7 +200,7 @@ export async function startNgrokTunnelAsync({
|
||||
};
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
|
||||
{ name: 'Ngrok tunnel open', timeoutMs, signal },
|
||||
async openSignal => {
|
||||
openSignal.throwIfAborted();
|
||||
const opening = ngrok
|
||||
@@ -184,13 +217,28 @@ export async function startNgrokTunnelAsync({
|
||||
pendingOpen = undefined;
|
||||
});
|
||||
pendingOpen = opening;
|
||||
void opening
|
||||
.then(() => {
|
||||
// Records how long a timed-out or cancelled open takes to settle, to tune the time-out.
|
||||
const logLateSettlement = (outcome: 'opened' | 'failed', err?: unknown) => {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
logger.info(
|
||||
{ err, outcome, elapsedMs },
|
||||
`ngrok tunnel open for ${domain} ${outcome} after ${elapsedMs} ms, after it timed out or was cancelled.`
|
||||
);
|
||||
};
|
||||
void opening.then(
|
||||
() => {
|
||||
if (openSignal.aborted) {
|
||||
logLateSettlement('opened');
|
||||
retireAcquired();
|
||||
}
|
||||
})
|
||||
.catch(() => releaseTarget());
|
||||
},
|
||||
err => {
|
||||
if (openSignal.aborted) {
|
||||
logLateSettlement('failed', err);
|
||||
}
|
||||
releaseTarget();
|
||||
}
|
||||
);
|
||||
const listener = await opening;
|
||||
openSignal.throwIfAborted();
|
||||
return listener;
|
||||
@@ -202,10 +250,56 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
}
|
||||
|
||||
// ngrok sometimes rejects an open because a control-plane request failed (for example
|
||||
// ERR_NGROK_802), so a rejected first open is retried before the session fails. A timed-out
|
||||
// open is not retried: it still holds the ngrok lock, so a retry would only wait behind it.
|
||||
async function openInitialAsync(): Promise<ngrok.Listener> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await openAsync({
|
||||
// ERR_NGROK_802 is a server error while the endpoint binds on the shared agent session,
|
||||
// so a retry connects a new session, which can reach a different ngrok server.
|
||||
// @ngrok/ngrok 1.7.0 replaces its shared session but does not close the old one: the old
|
||||
// session and its listeners stay connected until the process exits.
|
||||
forceNewSession: attempt > 1,
|
||||
timeoutMs: attempt === 1 ? FIRST_OPEN_TIMEOUT_MS : OPEN_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
// A cancelled startup fails with the caller's reason, so the caller can tell it from an error.
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
attempt >= INITIAL_OPEN_ATTEMPTS ||
|
||||
!isRetryableNgrokError(err) ||
|
||||
retrySignal?.aborted
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
logger.warn(
|
||||
{ err, attempt },
|
||||
`Could not open ngrok tunnel ${domain} (attempt ${attempt} of ${INITIAL_OPEN_ATTEMPTS}); retrying.`
|
||||
);
|
||||
await waitAsync(
|
||||
INITIAL_OPEN_RETRY_DELAY_MS * attempt,
|
||||
retrySignal ? AbortSignal.any([signal, retrySignal]) : signal
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (retrySignal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added after the target listens, so the finally below always removes it.
|
||||
const abortStartup = () => controller.abort(startupSignal?.reason);
|
||||
startupSignal?.addEventListener('abort', abortStartup, { once: true });
|
||||
if (startupSignal?.aborted) {
|
||||
abortStartup();
|
||||
}
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
let publicUrl: string | null;
|
||||
try {
|
||||
currentListener = await openAsync();
|
||||
currentListener = await openInitialAsync();
|
||||
publicUrl = currentListener.url();
|
||||
if (!publicUrl) {
|
||||
await closeAsync(currentListener, signal);
|
||||
@@ -216,6 +310,8 @@ export async function startNgrokTunnelAsync({
|
||||
controller.abort();
|
||||
releaseTarget();
|
||||
throw error;
|
||||
} finally {
|
||||
startupSignal?.removeEventListener('abort', abortStartup);
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
@@ -299,7 +395,7 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
attempts++;
|
||||
try {
|
||||
const reopened = await openAsync(/* forceNewSession */ true);
|
||||
const reopened = await openAsync({ forceNewSession: true });
|
||||
if (reopened.url() !== url) {
|
||||
await closeAsync(reopened, signal);
|
||||
throw new SystemError(
|
||||
@@ -350,6 +446,26 @@ export async function startNgrokTunnelAsync({
|
||||
};
|
||||
}
|
||||
|
||||
function isRetryableNgrokError(err: unknown): boolean {
|
||||
const code = getNgrokErrorCode(err);
|
||||
return code !== null && RETRYABLE_NGROK_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ngrok error code of a failed open. @ngrok/ngrok sets `errorCode` from the message. When it is
|
||||
* missing, the code is read from the message here.
|
||||
*/
|
||||
function getNgrokErrorCode(err: unknown): string | null {
|
||||
if (!(err instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
const { errorCode } = err as { errorCode?: unknown };
|
||||
if (typeof errorCode === 'string') {
|
||||
return errorCode;
|
||||
}
|
||||
return /\berror_code: (ERR_NGROK_\d+)\b/.exec(err.message)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
// expo-device-hub serves serve-emu, which records Android preview input, under this prefix.
|
||||
const EXPO_DEVICE_HUB_SERVE_EMU_PREFIX = '/vendor/serve-emu';
|
||||
|
||||
/** The preview server whose input log counts as session activity. */
|
||||
export type PreviewActivityServer = 'serve-sim' | 'expo-device-hub';
|
||||
|
||||
export type PreviewActivityPolling = {
|
||||
/**
|
||||
* When a new input log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while a read fails for a reason other than a refused connection or a device-unavailable
|
||||
* answer, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** What one read of the server's input log shows. */
|
||||
export type PreviewInputLogState = {
|
||||
/** The newest entry's ID, or undefined when the log is empty. */
|
||||
latestEventId: number | undefined;
|
||||
/**
|
||||
* When the server logged the newest entry. A new recording starts its IDs again at 1, so the ID
|
||||
* alone can repeat for new input.
|
||||
*/
|
||||
latestEventAt: string | undefined;
|
||||
/** The server is replaying recorded input, which it does not log again. */
|
||||
replaying: boolean;
|
||||
/**
|
||||
* When the latest replay started, kept after it ends, so a replay that runs entirely between two
|
||||
* reads still shows up as a change.
|
||||
*/
|
||||
replayStartedAt: string | undefined;
|
||||
};
|
||||
|
||||
type ReadInputLogAsync = (options: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<PreviewInputLogState>;
|
||||
|
||||
const READ_INPUT_LOG: Record<PreviewActivityServer, ReadInputLogAsync> = {
|
||||
'serve-sim': readServeSimInputLogAsync,
|
||||
'expo-device-hub': readExpoDeviceHubInputLogAsync,
|
||||
};
|
||||
|
||||
/**
|
||||
* The server is up but has no device to take input, such as an offline emulator. Like a down
|
||||
* server, it receives no input, so the last observation stands.
|
||||
*/
|
||||
class PreviewDeviceUnavailableError extends Error {}
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running server. */
|
||||
function isServerDownError(err: unknown): boolean {
|
||||
if (err instanceof PreviewDeviceUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
// 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, a new replay, or a running replay counts as activity, timed by the
|
||||
* local clock.
|
||||
*/
|
||||
export function startPreviewActivityPolling({
|
||||
server,
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
||||
}: {
|
||||
server: PreviewActivityServer;
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}): PreviewActivityPolling {
|
||||
const readInputLogAsync = READ_INPUT_LOG[server];
|
||||
const controller = new AbortController();
|
||||
let lastEventKey: string | undefined;
|
||||
let lastReplayStartedAt: string | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarnUnavailable = false;
|
||||
let didWarnDown = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const { latestEventId, latestEventAt, replaying, replayStartedAt } = await readInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeoutMs)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
const eventKey =
|
||||
latestEventId === undefined ? undefined : `${latestEventId}@${latestEventAt ?? ''}`;
|
||||
if (eventKey !== undefined && eventKey !== lastEventKey) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
lastEventKey = eventKey;
|
||||
if (replayStartedAt !== undefined && replayStartedAt !== lastReplayStartedAt) {
|
||||
lastReplayStartedAt = replayStartedAt;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
if (replaying) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down server or device, which receives no input either, keeps the last observation.
|
||||
// A timeout proves neither: expo-device-hub holds the read while it changes the capture
|
||||
// source, and the open preview keeps taking input meanwhile.
|
||||
eventLogUnavailable = !isServerDownError(err);
|
||||
if (eventLogUnavailable && !didWarnUnavailable) {
|
||||
didWarnUnavailable = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not read the ${server} input log. Preview input cannot be observed, so the ` +
|
||||
'idle timeout does not stop this session while the log cannot be read.'
|
||||
);
|
||||
} else if (!eventLogUnavailable && !didWarnDown) {
|
||||
didWarnDown = true;
|
||||
logger.warn({ err }, `Could not read ${server} 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 readServeSimInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
url: `http://127.0.0.1:${port}/api/event-log?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'serve-sim event log',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads serve-emu's session recording through expo-device-hub. It records browser preview and REST
|
||||
* input: touches, keys, text, navigation buttons, and location. It does not record input sent with
|
||||
* `record: false` (the preview's Reload button) or actions such as rotation and screenshots, so
|
||||
* those alone do not keep a session active. Controller commands count through the session's own
|
||||
* event stream. The hub scopes the request to its single device.
|
||||
*/
|
||||
export async function readExpoDeviceHubInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
// The hub returns the whole recording and ignores `limit`; serve-emu's paged route honors it.
|
||||
url: `http://127.0.0.1:${port}${EXPO_DEVICE_HUB_SERVE_EMU_PREFIX}/api/session?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'expo-device-hub input log',
|
||||
// The hub answers 503 while it cannot reach the emulator.
|
||||
deviceUnavailableStatus: 503,
|
||||
});
|
||||
}
|
||||
|
||||
async function readInputLogAsync({
|
||||
url,
|
||||
token,
|
||||
signal,
|
||||
logName,
|
||||
deviceUnavailableStatus,
|
||||
}: {
|
||||
url: string;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
logName: string;
|
||||
deviceUnavailableStatus?: number;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const message = `${logName} request failed: ${response.status}`;
|
||||
throw response.status === deviceUnavailableStatus
|
||||
? new PreviewDeviceUnavailableError(message)
|
||||
: new Error(message);
|
||||
}
|
||||
const text = await response.text();
|
||||
type ReplayState = { replaying?: unknown; replayStartedAt?: unknown };
|
||||
// The hub route returns the recorder snapshot; serve-emu's paged route nests it under `session`.
|
||||
let body: ({ events?: unknown; session?: ReplayState } & ReplayState) | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error(`${logName} response has no events array.`);
|
||||
}
|
||||
const replayState: ReplayState = body.session ?? body;
|
||||
const replaying = replayState.replaying === true;
|
||||
const replayStartedAt =
|
||||
typeof replayState.replayStartedAt === 'string' ? replayState.replayStartedAt : undefined;
|
||||
if (body.events.length === 0) {
|
||||
return { latestEventId: undefined, latestEventAt: undefined, replaying, replayStartedAt };
|
||||
}
|
||||
const latest = body.events.at(-1) as { id?: unknown; at?: unknown; timestamp?: unknown } | null;
|
||||
if (typeof latest?.id !== 'number') {
|
||||
throw new Error(`${logName} entry has no numeric ID.`);
|
||||
}
|
||||
// serve-emu names the time `at`; serve-sim names it `timestamp`.
|
||||
const at = latest.at ?? latest.timestamp;
|
||||
return {
|
||||
latestEventId: latest.id,
|
||||
latestEventAt: typeof at === 'string' ? at : undefined,
|
||||
replaying,
|
||||
replayStartedAt,
|
||||
};
|
||||
}
|
||||
@@ -649,34 +649,66 @@ export async function stopDetachedProcessAsync(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
* Teardown options for the stop of a tool tunnel (agent-device, Appium, or Argent) at the end of a
|
||||
* session. The stop first makes the tunnel's local proxy drop every connection (see
|
||||
* `startNgrokTunnelAsync`), so a listener that does not close in time cannot reach the session. The
|
||||
* worker process exits after the job, and this closes the process's ngrok sessions. The warm-host
|
||||
* preview tunnel does not use these options: its retirement still fails the session.
|
||||
*/
|
||||
export const TOOL_TUNNEL_TEARDOWN_OPTIONS = { failsSession: false } as const;
|
||||
|
||||
/** A named task that `finishRemoteSessionAsync` waits for when a remote session ends. */
|
||||
export type RemoteSessionTeardown = [
|
||||
name: string,
|
||||
task: Promise<unknown> | undefined,
|
||||
options?: {
|
||||
/**
|
||||
* False when a failure of this task cannot affect the user or a later session, so it is
|
||||
* logged and reported to Sentry but does not fail a session that succeeded. Defaults to true.
|
||||
*/
|
||||
failsSession?: boolean;
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure of a task that
|
||||
* fails the session is rethrown only when the session body succeeded, so a teardown error cannot
|
||||
* replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
teardown: RemoteSessionTeardown[];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
const failures = results.flatMap((result, index) => {
|
||||
if (result.status !== 'rejected') {
|
||||
return [];
|
||||
}
|
||||
const [name, , options] = teardown[index];
|
||||
return [{ name, err: result.reason, failsSession: options?.failsSession ?? true }];
|
||||
});
|
||||
for (const { name, err, failsSession } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
if (sessionFailed || !failsSession) {
|
||||
// The step does not report this failure, so it goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
Sentry.capture(
|
||||
sessionFailed
|
||||
? `Could not stop the ${name} after the remote session failed`
|
||||
: `Could not stop the ${name} after the remote session ended`,
|
||||
error,
|
||||
{ level: 'warning' }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
const sessionFailure = failures.find(({ failsSession }) => failsSession);
|
||||
if (!sessionFailed && sessionFailure) {
|
||||
throw sessionFailure.err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import { Socket, createServer } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
@@ -35,6 +35,8 @@ export type AndroidEmulatorLaunchGate = {
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
const FIRST_CONSOLE_PORT = 5554;
|
||||
const LAST_CONSOLE_PORT = 5584;
|
||||
|
||||
export const defaultSystemImagePackage = `system-images;android-30;default;${
|
||||
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
|
||||
@@ -329,12 +331,24 @@ export namespace AndroidEmulatorUtils {
|
||||
logcatDirectory,
|
||||
launchGate,
|
||||
logCapture,
|
||||
consolePort,
|
||||
extraArgs = [],
|
||||
extraEnv = {},
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
/**
|
||||
* The console port, from `findFreeConsolePortAsync`. It sets the serial (`emulator-<port>`)
|
||||
* before the start. Without it, the emulator picks a free port itself.
|
||||
*/
|
||||
consolePort?: number;
|
||||
/** Emulator arguments before $ANDROID_EMULATOR_EXTRA_ARGS, which can still override them. */
|
||||
extraArgs?: string[];
|
||||
/** Environment variables for the emulator process only. */
|
||||
extraEnv?: Record<string, string>;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
@@ -375,12 +389,15 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(consolePort !== undefined ? ['-port', String(consolePort)] : []),
|
||||
...extraArgs,
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
];
|
||||
const emulatorEnv = {
|
||||
...env,
|
||||
...extraEnv,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
};
|
||||
@@ -614,6 +631,30 @@ export namespace AndroidEmulatorUtils {
|
||||
return { outputPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* A free console port: an even port from 5554 to 5584, the range that adb scans, whose next
|
||||
* port, for adb, is free too.
|
||||
*/
|
||||
export async function findFreeConsolePortAsync(): Promise<number> {
|
||||
for (let port = FIRST_CONSOLE_PORT; port <= LAST_CONSOLE_PORT; port += 2) {
|
||||
if ((await isPortFreeAsync(port)) && (await isPortFreeAsync(port + 1))) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No emulator console port from ${FIRST_CONSOLE_PORT} to ${LAST_CONSOLE_PORT} is free.`
|
||||
);
|
||||
}
|
||||
|
||||
async function isPortFreeAsync(port: number): Promise<boolean> {
|
||||
return await new Promise(resolve => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once('error', () => resolve(false));
|
||||
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopAsync({
|
||||
serialId,
|
||||
env,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import { type Server, Socket, createServer } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -408,6 +408,43 @@ describe('AndroidEmulatorUtils', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds the console port and extra arguments before $ANDROID_EMULATOR_EXTRA_ARGS', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
consolePort: 5556,
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: { LD_PRELOAD: '/tmp/libexpo_emulator_capture.so' },
|
||||
});
|
||||
|
||||
const [, args, options] = mockedSpawn.mock.calls.find(([command]) =>
|
||||
command.endsWith('/emulator/emulator')
|
||||
)!;
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'-accel',
|
||||
'on',
|
||||
'-port',
|
||||
'5556',
|
||||
'-gpu',
|
||||
'lavapipe',
|
||||
'-gpu',
|
||||
'swiftshader_indirect',
|
||||
]);
|
||||
expect(options?.env).toEqual({
|
||||
...env,
|
||||
LD_PRELOAD: '/tmp/libexpo_emulator_capture.so',
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the emulator behind the launch gate and releases it once admitted', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
@@ -529,6 +566,60 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.findFreeConsolePortAsync, () => {
|
||||
/** Listens on the port, or returns null when another process already uses it. */
|
||||
async function occupyAsync(port: number): Promise<Server | null> {
|
||||
const server = createServer();
|
||||
return await new Promise(resolve => {
|
||||
server.once('error', () => resolve(null));
|
||||
server.listen(port, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function closeAsync(servers: (Server | null)[]): Promise<void> {
|
||||
await Promise.all(
|
||||
servers.map(server => (server ? new Promise(resolve => server.close(resolve)) : null))
|
||||
);
|
||||
}
|
||||
|
||||
it('returns an even console port that adb scans', async () => {
|
||||
const port = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
|
||||
expect(port % 2).toBe(0);
|
||||
expect(port).toBeGreaterThanOrEqual(5554);
|
||||
expect(port).toBeLessThanOrEqual(5584);
|
||||
});
|
||||
|
||||
it('skips a console port whose adb port is in use', async () => {
|
||||
const port = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
const adbServer = await occupyAsync(port + 1);
|
||||
try {
|
||||
expect(adbServer).not.toBeNull();
|
||||
const next = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
|
||||
expect(next).not.toBe(port);
|
||||
expect(next % 2).toBe(0);
|
||||
} finally {
|
||||
await closeAsync([adbServer]);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when no console port is free', async () => {
|
||||
const servers: (Server | null)[] = [];
|
||||
try {
|
||||
for (let port = 5555; port <= 5585; port += 2) {
|
||||
servers.push(await occupyAsync(port));
|
||||
}
|
||||
|
||||
await expect(AndroidEmulatorUtils.findFreeConsolePortAsync()).rejects.toThrow(
|
||||
'No emulator console port from 5554 to 5584 is free.'
|
||||
);
|
||||
} finally {
|
||||
await closeAsync(servers);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
|
||||
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
|
||||
@@ -1,44 +1,28 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import { isProcessGroupRunning, isProcessRunning } from '../processes';
|
||||
|
||||
import { isChildProcessAlive, isProcessGroupRunning } from '../processes';
|
||||
|
||||
function child(partial: {
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
}): ChildProcess {
|
||||
return partial as ChildProcess;
|
||||
}
|
||||
|
||||
describe(isChildProcessAlive, () => {
|
||||
it('is alive while the process is running', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: false }))).toBe(
|
||||
true
|
||||
);
|
||||
describe(isProcessRunning, () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is dead after a normal exit', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: 0, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isChildProcessAlive(child({ exitCode: 1, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
it('signals the process, not its group', () => {
|
||||
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
expect(isProcessRunning(4321)).toBe(true);
|
||||
expect(kill).toHaveBeenCalledWith(4321, 0);
|
||||
});
|
||||
|
||||
it('is dead after an external signal termination (the regression case)', () => {
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGTERM', killed: false }))
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGKILL', killed: false }))
|
||||
).toBe(false);
|
||||
it('is not running once the process is gone', () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('No such process'), { code: 'ESRCH' });
|
||||
});
|
||||
expect(isProcessRunning(4321)).toBe(false);
|
||||
});
|
||||
|
||||
it('is dead once we have killed it', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: true }))).toBe(
|
||||
false
|
||||
);
|
||||
it('is running when the process exists but cannot be signaled', () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('Operation not permitted'), { code: 'EPERM' });
|
||||
});
|
||||
expect(isProcessRunning(4321)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
startSshSessionAsync,
|
||||
superviseSshSessionAsync,
|
||||
} from '../turtleSshSession';
|
||||
import { SshConnectionConfig, UptermHost, startUptermHostAsync } from '../upterm';
|
||||
import {
|
||||
SshConnectionConfig,
|
||||
UptermHost,
|
||||
UptermSessionStatus,
|
||||
startUptermHostAsync,
|
||||
} from '../upterm';
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -28,13 +33,14 @@ function sshJob(idleTimeoutSeconds: number): { ssh: SshSettings } {
|
||||
}
|
||||
|
||||
function superviseHandle(
|
||||
overrides: Partial<SshSessionHandle> &
|
||||
Pick<SshSessionHandle, 'getConnectedClientCountAsync' | 'ensureConnectedAsync'>
|
||||
readStatusAsync: () => Promise<UptermSessionStatus>,
|
||||
reportReconnectingAsync: (reconnecting: boolean) => Promise<void> = async () => {}
|
||||
): SshSessionHandle {
|
||||
return {
|
||||
stopAsync: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
return { readStatusAsync, reportReconnectingAsync, stopAsync: async () => {} };
|
||||
}
|
||||
|
||||
function ready(connectedClientCount: number): UptermSessionStatus {
|
||||
return { state: 'ready', connectedClientCount };
|
||||
}
|
||||
|
||||
describe(isSshEnabled, () => {
|
||||
@@ -77,7 +83,6 @@ describe(getSshRelayServerUrl, () => {
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
let createOrUpdateResult: { data?: unknown; error?: { message: string } };
|
||||
@@ -85,18 +90,17 @@ describe(startSshSessionAsync, () => {
|
||||
let ctx: BuildContext;
|
||||
|
||||
function makeHost(overrides: Partial<UptermHost> = {}): UptermHost {
|
||||
const host: UptermHost = {
|
||||
return {
|
||||
connectionConfig: config1,
|
||||
getConnectedClientCountAsync: jest.fn().mockResolvedValue(3),
|
||||
isAlive: jest.fn().mockReturnValue(true),
|
||||
redialAsync: jest.fn(async () => {
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
}),
|
||||
startedReconnecting: false,
|
||||
readStatusAsync: jest.fn().mockResolvedValue(ready(3)),
|
||||
stopAsync: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
function reportedReconnecting(): boolean[] {
|
||||
return mutation.mock.calls.map(([, vars]) => vars.connectionConfig.reconnecting);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -133,8 +137,7 @@ describe(startSshSessionAsync, () => {
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
await handle.getConnectedClientCountAsync();
|
||||
expect(host.getConnectedClientCountAsync).toHaveBeenCalled();
|
||||
expect(await handle.readStatusAsync()).toEqual(ready(3));
|
||||
await handle.stopAsync();
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
@@ -203,117 +206,61 @@ describe(startSshSessionAsync, () => {
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureConnected is a no-op while the host is alive', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(true) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
it('tells www about reconnecting only when it changes, with the same session id', async () => {
|
||||
mockedStartUptermHost.mockResolvedValue(makeHost());
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
for (const reconnecting of [false, true, true, false, false]) {
|
||||
await handle.reportReconnectingAsync(reconnecting);
|
||||
}
|
||||
|
||||
expect(host.redialAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks reconnecting, redials, then reports the fresh config', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
connectionConfig: expect.objectContaining({ reconnecting: true }),
|
||||
})
|
||||
);
|
||||
expect(reportedReconnecting()).toEqual([false, true, false]);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('continues redial when the reconnecting status update fails', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
it('sends the start status in the create call and tracks changes from it', async () => {
|
||||
mockedStartUptermHost.mockResolvedValue(makeHost({ startedReconnecting: true }));
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
for (const reconnecting of [true, false, false]) {
|
||||
await handle.reportReconnectingAsync(reconnecting);
|
||||
}
|
||||
|
||||
expect(reportedReconnecting()).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('retries a failed report on the next call and drops it when the state flips back first', async () => {
|
||||
mockedStartUptermHost.mockResolvedValue(makeHost());
|
||||
mutation
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult }) // initial create
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
throw new Error('www unreachable');
|
||||
},
|
||||
}) // reconnecting=true best-effort
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult }); // post-redial
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the report (no second redial) when reporting the fresh config fails', async () => {
|
||||
let alive = false;
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn(() => alive),
|
||||
});
|
||||
host.redialAsync = jest.fn(async () => {
|
||||
alive = true;
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'www down' } }) })
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'flaky' } }) })
|
||||
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'www down' } }) })
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult });
|
||||
ctx = { logger: createMockLogger(), graphqlClient: { mutation } } as unknown as BuildContext;
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
for (const reconnecting of [true, true, false, true, false]) {
|
||||
await handle.reportReconnectingAsync(reconnecting);
|
||||
}
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws once the redial budget is exhausted', async () => {
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn().mockReturnValue(false),
|
||||
redialAsync: jest.fn().mockRejectedValue(new Error('relay down')),
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
|
||||
await expect(handle.ensureConnectedAsync()).rejects.toThrow(/after 10 attempts/);
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(10);
|
||||
// create, true (fails), true, false (fails), nothing while www still has true, false.
|
||||
expect(reportedReconnecting()).toEqual([false, true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,33 +276,23 @@ describe(superviseSshSessionAsync, () => {
|
||||
it('keeps the session open while the job is running, even with a 0 idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls >= 5) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
return polls > 5 ? { state: 'gone', reason: 'ending' } : ready(0);
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(5);
|
||||
expect(polls).toBe(6);
|
||||
});
|
||||
|
||||
it('closes as soon as the job finishes when the idle timeout is 0', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
return ready(0);
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
@@ -371,12 +308,9 @@ describe(superviseSshSessionAsync, () => {
|
||||
const clientCounts = [1, 1, 0];
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return clientCounts.shift() ?? 0;
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
return ready(clientCounts.shift() ?? 0);
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
@@ -395,15 +329,12 @@ describe(superviseSshSessionAsync, () => {
|
||||
jest.setSystemTime(Date.now() + 60_000);
|
||||
});
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
if (polls >= 5) {
|
||||
jobHasFinished = true;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
if (polls >= 5) {
|
||||
jobHasFinished = true;
|
||||
}
|
||||
return ready(0);
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => jobHasFinished,
|
||||
@@ -420,10 +351,7 @@ describe(superviseSshSessionAsync, () => {
|
||||
it('logs the connected client count whenever it changes', async () => {
|
||||
const clientCounts = [1, 2, 1, 0];
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => clientCounts.shift() ?? 0,
|
||||
}),
|
||||
handle: superviseHandle(async () => ready(clientCounts.shift() ?? 0)),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
@@ -434,57 +362,78 @@ describe(superviseSshSessionAsync, () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 0');
|
||||
});
|
||||
|
||||
it('closes the session when the relay connection cannot be restored', async () => {
|
||||
let clientCountCalls = 0;
|
||||
it('keeps the session open while upterm reconnects, then closes it once it is gone for good', async () => {
|
||||
const statuses: UptermSessionStatus[] = [
|
||||
{ state: 'reconnecting', connectedClientCount: 0 },
|
||||
{ state: 'reconnecting', connectedClientCount: 0 },
|
||||
{ state: 'gone', reason: 'disconnected (reconnect_unsupported)' },
|
||||
ready(1),
|
||||
];
|
||||
const reportReconnectingAsync = jest.fn(async () => {});
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
throw new SystemError('relay down');
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
clientCountCalls += 1;
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
return statuses.shift() ?? ready(1);
|
||||
}, reportReconnectingAsync),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(clientCountCalls).toBe(0);
|
||||
expect(polls).toBe(3);
|
||||
expect(reportReconnectingAsync.mock.calls).toEqual([[true], [true]]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Could not restore the SSH relay connection (disconnected (reconnect_unsupported)). Closing the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the reconnecting state of every poll and logs only its changes', async () => {
|
||||
const reconnecting: UptermSessionStatus = { state: 'reconnecting', connectedClientCount: 0 };
|
||||
const statuses: UptermSessionStatus[] = [
|
||||
ready(0),
|
||||
reconnecting,
|
||||
reconnecting,
|
||||
ready(0),
|
||||
{ state: 'gone', reason: 'ending' },
|
||||
];
|
||||
const reportReconnectingAsync = jest.fn(async () => {});
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle(async () => statuses.shift() ?? ready(0), reportReconnectingAsync),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(reportReconnectingAsync.mock.calls).toEqual([[false], [true], [true], [false]]);
|
||||
expect(
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.filter(
|
||||
([message]) => message === 'The SSH relay connection dropped. Reconnecting...'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('The SSH relay connection was restored.');
|
||||
});
|
||||
|
||||
it('does not close while a client stays connected, even past the idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls > 3) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 1;
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
return polls > 4 ? { state: 'gone', reason: 'ending' } : ready(1);
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBeGreaterThan(3);
|
||||
expect(polls).toBe(5);
|
||||
});
|
||||
|
||||
it('closes when the client count cannot be read', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
throw new SystemError(
|
||||
'Could not read the SSH client count from the upterm admin socket.'
|
||||
);
|
||||
},
|
||||
handle: superviseHandle(async () => {
|
||||
polls += 1;
|
||||
throw new SystemError('Could not read the SSH client count from the upterm admin socket.');
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -11,7 +10,6 @@ import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
startUptermHostAsync,
|
||||
} from '../upterm';
|
||||
@@ -22,16 +20,38 @@ jest.mock('../retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
access: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
chmod: jest.fn(),
|
||||
mkdtemp: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SESSION_ID = 'pJbIEKJ3dhB4qIUMoNku';
|
||||
|
||||
const DETACH_JSON = JSON.stringify(
|
||||
{
|
||||
name: 'eas-1a2b3c4',
|
||||
launchId: '71c3af039c2557ea',
|
||||
status: 'ready',
|
||||
sessionId: SESSION_ID,
|
||||
adminSocket: '/tmp/eas-ssh-1/upterm/sessions/eas-1a2b3c4/admin.sock',
|
||||
attachSocket: '/tmp/eas-ssh-1/upterm/sessions/eas-1a2b3c4/attach.sock',
|
||||
pid: 73366,
|
||||
command: 'bash -lc sleep 21600',
|
||||
forceCommand: '/tmp/eas-ssh-1/join.sh',
|
||||
sshCommand: `ssh ${SESSION_ID}@relay.expo.dev -p 2222`,
|
||||
joinCommand: `upterm join ssh://${SESSION_ID}@relay.expo.dev:2222`,
|
||||
sshConfig: `Host eas-1a2b3c4\n HostName relay.expo.dev\n User ${SESSION_ID}\n Port 2222`,
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
clientCount: 0,
|
||||
guestCount: 0,
|
||||
reconnect: 'supported',
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
describe(resolveUptermGcsObjectName, () => {
|
||||
it('maps worker platforms to GCS object names', () => {
|
||||
expect(resolveUptermGcsObjectName('darwin', 'arm64')).toBe('upterm-darwin-arm64');
|
||||
@@ -45,36 +65,11 @@ describe(resolveUptermGcsObjectName, () => {
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
redactConnectionSecrets('Connect: upterm proxy wss://[email protected] now')
|
||||
).toBe('Connect: upterm proxy wss://<redacted>@relay.expo.dev now');
|
||||
});
|
||||
|
||||
it('strips the bare token that trails the ssh ProxyCommand line', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy wss://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy wss://<redacted>@uptermd.upterm.dev' <redacted>@uptermd.upterm.dev"
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the bare token for ws:// relay URLs too', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy ws://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy ws://<redacted>@relay.local' <redacted>@relay.local"
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves output without userinfo untouched', () => {
|
||||
expect(redactConnectionSecrets('dialing wss://relay.expo.dev')).toBe(
|
||||
'dialing wss://relay.expo.dev'
|
||||
);
|
||||
it('strips the session id from every field of the --detach JSON output', () => {
|
||||
const redacted = redactConnectionSecrets(DETACH_JSON);
|
||||
expect(redacted).not.toContain(SESSION_ID);
|
||||
expect(redacted).toContain('"sshCommand": "ssh <redacted>@relay.expo.dev -p 2222"');
|
||||
expect(redacted).toContain('User <redacted>');
|
||||
});
|
||||
|
||||
it('strips control characters (incl. ESC) while keeping newlines', () => {
|
||||
@@ -85,86 +80,25 @@ describe(redactConnectionSecrets, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactSpawnErrorForLog, () => {
|
||||
it('redacts secrets in message, stdout, and stderr', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 'failed wss://[email protected]',
|
||||
stdout: 'upterm proxy wss://[email protected]',
|
||||
stderr: 'ssh dial error: wss://[email protected]',
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 'failed wss://<redacted>@relay.expo.dev',
|
||||
stdout: 'upterm proxy wss://<redacted>@relay.expo.dev',
|
||||
stderr: 'ssh dial error: wss://<redacted>@relay.expo.dev',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through non-objects', () => {
|
||||
expect(redactSpawnErrorForLog('boom')).toBe('boom');
|
||||
expect(redactSpawnErrorForLog(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves non-string message/stdout/stderr fields alone', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(startUptermHostAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedSleep = jest.mocked(sleepAsync);
|
||||
const mockedFs = jest.mocked(fs);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
let hostProcess: {
|
||||
child: {
|
||||
stdout: { on: (event: string, cb: (chunk: Buffer) => void) => void };
|
||||
stderr: { on: jest.Mock };
|
||||
pid: number;
|
||||
kill: jest.Mock;
|
||||
unref: jest.Mock;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
catch: (cb: (err: unknown) => void) => Promise<void>;
|
||||
};
|
||||
let sessionStdout: string;
|
||||
let uptermOnPath = true;
|
||||
type SpawnCall = { cmd: string; args: string[]; options: any };
|
||||
let calls: SpawnCall[];
|
||||
let hostResult: () => Promise<{ stdout: string; stderr: string }>;
|
||||
let currentResult: () => Promise<{ stdout: string; stderr: string }>;
|
||||
let stopResult: () => Promise<{ stdout: string; stderr: string }>;
|
||||
let daemonRunning: boolean;
|
||||
let uptermOnPath: boolean;
|
||||
|
||||
function makeHostProcess(): typeof hostProcess {
|
||||
return {
|
||||
child: {
|
||||
stdout: {
|
||||
on: jest.fn((_event: string, cb: (chunk: Buffer) => void) => {
|
||||
cb(Buffer.from('host output'));
|
||||
}),
|
||||
},
|
||||
stderr: { on: jest.fn() },
|
||||
pid: 4242,
|
||||
kill: jest.fn(),
|
||||
unref: jest.fn(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
killed: false,
|
||||
},
|
||||
catch: cb => {
|
||||
cb(new Error('exited'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
function ok(stdout: string): () => Promise<{ stdout: string; stderr: string }> {
|
||||
return async () => ({ stdout, stderr: '' });
|
||||
}
|
||||
|
||||
function current(fields: Record<string, unknown>): string {
|
||||
return JSON.stringify({ sessionId: SESSION_ID, host: 'ssh://relay.expo.dev:2222', ...fields });
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
@@ -178,60 +112,103 @@ describe(startUptermHostAsync, () => {
|
||||
} as unknown as BuildContext;
|
||||
}
|
||||
|
||||
function callsOf(...prefix: string[]): SpawnCall[] {
|
||||
return calls.filter(c => prefix.every((arg, i) => c.args[i] === arg));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
hostProcess = makeHostProcess();
|
||||
uptermOnPath = true;
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
calls = [];
|
||||
daemonRunning = true;
|
||||
uptermOnPath = false;
|
||||
hostResult = ok(DETACH_JSON);
|
||||
currentResult = ok(current({ status: 'ready', guestCount: 0 }));
|
||||
stopResult = ok('session eas-1a2b3c4 stopped\n');
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
if (daemonRunning) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error('No such process'), { code: 'ESRCH' });
|
||||
});
|
||||
mockedSleep.mockResolvedValue(undefined);
|
||||
mockedFs.mkdir.mockResolvedValue(undefined as never);
|
||||
jest.mocked(sleepAsync).mockResolvedValue(undefined);
|
||||
mockedFs.chmod.mockResolvedValue(undefined as never);
|
||||
mockedFs.mkdtemp.mockImplementation((async (prefix: string) =>
|
||||
String(prefix).includes('eas-upterm') ? '/tmp/eas-upterm-1' : '/tmp/eas-ssh-1') as never);
|
||||
mockedFs.writeFile.mockResolvedValue(undefined as never);
|
||||
mockedFs.rm.mockResolvedValue(undefined as never);
|
||||
mockedFs.readdir.mockResolvedValue(['default.sock'] as never);
|
||||
mockedDownloadFile.mockResolvedValue(undefined as never);
|
||||
mockedSpawn.mockImplementation(((cmd: string, args: string[]) => {
|
||||
if (cmd === 'upterm' && Array.isArray(args) && args[0] === 'version') {
|
||||
if (!uptermOnPath) {
|
||||
return Promise.reject(new Error('not found')) as never;
|
||||
}
|
||||
return Promise.resolve({ stdout: 'upterm version 0.24.0\n', stderr: '' }) as never;
|
||||
mockedSpawn.mockImplementation(((cmd: string, args: string[], options: unknown) => {
|
||||
calls.push({ cmd, args, options });
|
||||
if (cmd === 'upterm' && args[0] === 'version') {
|
||||
return uptermOnPath
|
||||
? Promise.resolve({ stdout: 'Upterm version 0.0.0', stderr: '' })
|
||||
: Promise.reject(new Error('spawn upterm ENOENT'));
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'host') {
|
||||
return hostProcess as never;
|
||||
if (args[0] === 'host') {
|
||||
return hostResult();
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'session') {
|
||||
return Promise.resolve({ stdout: sessionStdout, stderr: '' }) as never;
|
||||
if (args[0] === 'session' && args[1] === 'current') {
|
||||
return currentResult();
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' }) as never;
|
||||
if (args[0] === 'session' && args[1] === 'stop') {
|
||||
return stopResult();
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('uses upterm on PATH, dials, and returns a handle with the parsed config', async () => {
|
||||
const env = { PATH: '/job/bin', CUSTOM: '1' };
|
||||
const host = await startUptermHostAsync(makeCtx(env), {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts a detached session under a short unique name and takes the session id from its JSON', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx({ CUSTOM: '1' }), {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
|
||||
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'upterm',
|
||||
expect.arrayContaining(['host']),
|
||||
expect.objectContaining({ env: expect.objectContaining({ CUSTOM: '1', PATH: '/job/bin' }) })
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: SESSION_ID });
|
||||
const [first, second] = callsOf('host');
|
||||
const nameOf = (call: SpawnCall): string => call.args[call.args.indexOf('--name') + 1];
|
||||
expect(nameOf(first).length).toBeLessThanOrEqual(11);
|
||||
expect(nameOf(first)).not.toBe(nameOf(second));
|
||||
expect(first.args).toEqual(
|
||||
expect.arrayContaining(['--accept', '--detach', '--output', 'json'])
|
||||
);
|
||||
expect(first.args.slice(first.args.indexOf('--'))).toEqual([
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
'sleep 21600',
|
||||
]);
|
||||
expect(first.options).toMatchObject({
|
||||
env: { CUSTOM: '1', XDG_RUNTIME_DIR: '/tmp/eas-ssh-1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports whether the session was already reconnecting when it started', async () => {
|
||||
hostResult = ok(DETACH_JSON.replace('"status": "ready"', '"status": "reconnecting"'));
|
||||
const reconnecting = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostResult = ok(DETACH_JSON);
|
||||
const ready = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(reconnecting.startedReconnecting).toBe(true);
|
||||
expect(ready.startedReconnecting).toBe(false);
|
||||
});
|
||||
|
||||
it('downloads upterm from GCS even when one is on PATH', async () => {
|
||||
uptermOnPath = true;
|
||||
|
||||
await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
|
||||
|
||||
expect(calls.some(c => c.cmd === 'upterm')).toBe(false);
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/upterm/' + resolveUptermGcsObjectName(),
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.objectContaining({ retry: 3 })
|
||||
);
|
||||
expect(callsOf('host')[0].cmd).toContain('/tmp/eas-upterm-1');
|
||||
});
|
||||
|
||||
it('starts each client shell in the build directory', async () => {
|
||||
@@ -261,32 +238,38 @@ describe(startUptermHostAsync, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
|
||||
uptermOnPath = false;
|
||||
it("runs the client's command in the build directory and returns its exit status", async () => {
|
||||
const realFs = jest.requireActual<typeof import('node:fs')>('node:fs');
|
||||
const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'eas-upterm-join-'));
|
||||
try {
|
||||
const buildDirectory = path.join(tmpDir, "it's $HOME `pwd`", 'build');
|
||||
realFs.mkdirSync(buildDirectory, { recursive: true });
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
|
||||
await startUptermHostAsync(makeCtx({}, buildDirectory), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.objectContaining({ retry: 3 })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.arrayContaining(['host']),
|
||||
expect.anything()
|
||||
);
|
||||
const joinScriptCall = mockedFs.writeFile.mock.calls.find(
|
||||
([file]) => file === '/tmp/eas-ssh-1/join.sh'
|
||||
);
|
||||
const joinScriptPath = path.join(tmpDir, 'join.sh');
|
||||
realFs.writeFileSync(joinScriptPath, String(joinScriptCall?.[1]));
|
||||
const result = spawnSync('bash', [joinScriptPath], {
|
||||
cwd: tmpDir,
|
||||
env: { PATH: process.env.PATH, HOME: tmpDir, SSH_ORIGINAL_COMMAND: 'pwd; exit 3' },
|
||||
input: '',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
expect(result.stdout.trim()).toBe(buildDirectory);
|
||||
expect(result.status).toBe(3);
|
||||
} finally {
|
||||
realFs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads through the cache proxy when one is configured', async () => {
|
||||
uptermOnPath = false;
|
||||
|
||||
await startUptermHostAsync(
|
||||
makeCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://10.0.0.1:8081/repository/proxy' }),
|
||||
{
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
}
|
||||
{ relayServerUrl: 'wss://relay.expo.dev' }
|
||||
);
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledTimes(1);
|
||||
@@ -300,53 +283,33 @@ describe(startUptermHostAsync, () => {
|
||||
});
|
||||
|
||||
it('falls back to the direct URL when the proxied download fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValueOnce(new Error('502 Bad Gateway') as never);
|
||||
|
||||
const host = await startUptermHostAsync(
|
||||
await startUptermHostAsync(
|
||||
makeCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://10.0.0.1:8081/repository/proxy' }),
|
||||
{ relayServerUrl: 'wss://relay.expo.dev' }
|
||||
);
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
expect(mockedDownloadFile).toHaveBeenCalledTimes(2);
|
||||
expect(mockedDownloadFile).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/^https:\/\/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.objectContaining({ retry: 3 })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.arrayContaining(['host']),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('names the direct URL when both the proxied and the direct download fail', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
|
||||
|
||||
await expect(
|
||||
startUptermHostAsync(
|
||||
makeCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://10.0.0.1:8081/repository/proxy' }),
|
||||
{
|
||||
relayServerUrl: 'wss://r',
|
||||
}
|
||||
{ relayServerUrl: 'wss://r' }
|
||||
)
|
||||
).rejects.toThrow(/could not be downloaded from https:\/\/storage\.googleapis\.com/);
|
||||
});
|
||||
|
||||
it('throws when upterm is missing from PATH and the GCS download fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/could not be downloaded/
|
||||
);
|
||||
});
|
||||
|
||||
it('still throws when download fails with a non-Error and temp cleanup also fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue('network down' as never);
|
||||
mockedFs.rm.mockRejectedValue(new Error('EPERM') as never);
|
||||
|
||||
@@ -355,162 +318,180 @@ describe(startUptermHostAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until upterm reports a session id', async () => {
|
||||
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
|
||||
mockedSleep.mockImplementationOnce(async () => {
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENy',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
mockedSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 61_000);
|
||||
});
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/did not register/
|
||||
it('stops the session and removes the state and download dirs when upterm cannot start it', async () => {
|
||||
hostResult = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('upterm exited with non-zero code: 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr:
|
||||
'upterm: session eas-3418b73 could not start: ssh://127.0.0.1:32222: Permission denied (publickey); the 1 identity offered was refused.',
|
||||
})
|
||||
);
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stopAsync kills the process and removes the state dir', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await host.stopAsync();
|
||||
const error = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' }).catch(
|
||||
e => e
|
||||
);
|
||||
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(error.message).toMatch(/could not register with the relay/);
|
||||
expect(error.message).toContain('Permission denied (publickey)');
|
||||
expect(callsOf('session', 'stop')).toHaveLength(1);
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-upterm-1', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to child.kill when process.kill on the process group fails', async () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
it('reports a start that ran into its timeout as not registered in time', async () => {
|
||||
hostResult = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('upterm exited with signal: SIGTERM'), {
|
||||
status: null,
|
||||
signal: 'SIGTERM',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
})
|
||||
);
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/did not register with the relay within 60s/
|
||||
);
|
||||
expect(callsOf('host')[0].options).toMatchObject({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
it('stops a session whose start JSON says guests cannot reach it', async () => {
|
||||
hostResult = ok(DETACH_JSON.replace('"status": "ready"', '"status": "disconnected"'));
|
||||
|
||||
const error = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' }).catch(
|
||||
e => e
|
||||
);
|
||||
|
||||
expect(error.message).toMatch(/did not report a usable session/);
|
||||
expect(error.message).not.toContain(SESSION_ID);
|
||||
expect(callsOf('session', 'stop')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stopAsync stops the session by name with the start environment and removes the state and download dirs', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx({ CUSTOM: '1' }), {
|
||||
relayServerUrl: 'wss://r',
|
||||
});
|
||||
await host.stopAsync();
|
||||
|
||||
expect(hostProcess.child.kill).toHaveBeenCalled();
|
||||
const name = callsOf('host')[0].args[callsOf('host')[0].args.indexOf('--name') + 1];
|
||||
const [stop] = callsOf('session', 'stop');
|
||||
expect(stop.args).toEqual(['session', 'stop', name]);
|
||||
expect(stop.options.env).toMatchObject({ CUSTOM: '1', XDG_RUNTIME_DIR: '/tmp/eas-ssh-1' });
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-upterm-1', expect.anything());
|
||||
});
|
||||
|
||||
it('skips process.kill when the host process has no pid', async () => {
|
||||
it('stopAsync throws the stop output but still removes the state and download dirs when the stop fails', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.child.pid = undefined as unknown as number;
|
||||
await host.stopAsync();
|
||||
stopResult = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('exited with non-zero code: 1'), {
|
||||
stdout: '',
|
||||
stderr: "upterm: session eas-1a2b3c4 isn't answering on its admin socket (pid 73366)",
|
||||
})
|
||||
);
|
||||
|
||||
expect(process.kill).not.toHaveBeenCalled();
|
||||
expect(hostProcess.child.kill).not.toHaveBeenCalled();
|
||||
await expect(host.stopAsync()).rejects.toThrow(/isn't answering on its admin socket/);
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-upterm-1', expect.anything());
|
||||
});
|
||||
|
||||
it('redialAsync re-establishes the connection', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
describe('readStatusAsync', () => {
|
||||
it('reads the status and client count from the admin socket the start JSON named', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
currentResult = ok(current({ status: 'reconnecting', guestCount: 2 }));
|
||||
|
||||
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
it('redialAsync waits for the old process to exit before clearing its socket dir', async () => {
|
||||
const order: string[] = [];
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
// Keep the give-up timer pending so only the process exit can unblock the redial.
|
||||
mockedSleep.mockImplementation(() => new Promise<void>(() => {}));
|
||||
let resolveExit: (() => void) | undefined;
|
||||
hostProcess.catch = () =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveExit = () => {
|
||||
order.push('exited');
|
||||
resolve();
|
||||
};
|
||||
expect(await host.readStatusAsync()).toEqual({
|
||||
state: 'reconnecting',
|
||||
connectedClientCount: 2,
|
||||
});
|
||||
mockedFs.rm.mockImplementation((() => {
|
||||
order.push('rm');
|
||||
return Promise.resolve(undefined);
|
||||
}) as never);
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
order.push('kill');
|
||||
// A real SIGTERM is asynchronous, so the exit lands after the kill returns.
|
||||
setImmediate(() => resolveExit?.());
|
||||
return true;
|
||||
expect(callsOf('session', 'current')[0].args).toEqual([
|
||||
'session',
|
||||
'current',
|
||||
'--admin-socket',
|
||||
'/tmp/eas-ssh-1/upterm/sessions/eas-1a2b3c4/admin.sock',
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
});
|
||||
|
||||
await host.redialAsync();
|
||||
|
||||
expect(order).toEqual(['kill', 'exited', 'rm']);
|
||||
});
|
||||
|
||||
it('redialAsync gives up waiting when the old process does not exit', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
it('continues when the previous socket dir cannot be cleared', async () => {
|
||||
mockedFs.rm.mockImplementation(((dir: string) =>
|
||||
dir === '/tmp/eas-ssh-1/upterm'
|
||||
? Promise.reject(new Error('busy'))
|
||||
: Promise.resolve(undefined)) as never);
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
});
|
||||
|
||||
describe('getConnectedClientCountAsync', () => {
|
||||
it('throws when no admin socket exists yet (after retries)', async () => {
|
||||
it("counts guests only, not the session's own terminals", async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
currentResult = ok(current({ status: 'ready', clientCount: 1, guestCount: 0 }));
|
||||
|
||||
expect(await host.readStatusAsync()).toEqual({ state: 'ready', connectedClientCount: 0 });
|
||||
});
|
||||
|
||||
it('returns the client count reported by the admin socket', async () => {
|
||||
it.each([
|
||||
[
|
||||
{ status: 'disconnected', tunnelReason: 'reconnect_unsupported' },
|
||||
'disconnected (reconnect_unsupported)',
|
||||
],
|
||||
[{ status: 'ending' }, 'ending'],
|
||||
])('treats %j as gone for good', async (fields, reason) => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 4,
|
||||
currentResult = ok(current({ guestCount: 0, ...fields }));
|
||||
|
||||
expect(await host.readStatusAsync()).toEqual({ state: 'gone', reason });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'relay_key_changed',
|
||||
'auth_refused',
|
||||
'proof_refused',
|
||||
'agent_refused',
|
||||
'relay_unsupported',
|
||||
])('treats a reconnect blocked by %s as gone for good', async tunnelReason => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
currentResult = ok(current({ status: 'reconnecting', tunnelReason, guestCount: 0 }));
|
||||
|
||||
expect(await host.readStatusAsync()).toEqual({
|
||||
state: 'gone',
|
||||
reason: `reconnecting (${tunnelReason})`,
|
||||
});
|
||||
expect(await host.getConnectedClientCountAsync()).toBe(4);
|
||||
});
|
||||
|
||||
it('throws when the count query fails (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedSpawn.mockImplementation((() => Promise.reject(new Error('socket gone'))) as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
it.each(['network', 'relay_error', 'agent_unavailable'])(
|
||||
'keeps a session reconnecting after %s',
|
||||
async tunnelReason => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
currentResult = ok(current({ status: 'reconnecting', tunnelReason, guestCount: 0 }));
|
||||
|
||||
it('throws when the reported count is not a number (after retries)', async () => {
|
||||
expect(await host.readStatusAsync()).toEqual({
|
||||
state: 'reconnecting',
|
||||
connectedClientCount: 0,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('reports the session as gone when the read fails and the session process has exited', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 'nope',
|
||||
currentResult = () => Promise.reject(new Error('exited with non-zero code: 4'));
|
||||
daemonRunning = false;
|
||||
|
||||
expect(await host.readStatusAsync()).toEqual({
|
||||
state: 'gone',
|
||||
reason: 'the upterm session process exited',
|
||||
});
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
expect(process.kill).toHaveBeenCalledWith(73366, 0);
|
||||
});
|
||||
|
||||
it('throws when the socket dir cannot be read (after retries)', async () => {
|
||||
it('retries a failed read while the session process runs, then throws', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockRejectedValue(new Error('no dir') as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
currentResult = () => Promise.reject(new Error('deadline exceeded'));
|
||||
|
||||
await expect(host.readStatusAsync()).rejects.toThrow(/client count/i);
|
||||
expect(callsOf('session', 'current')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('does not guess at a status it does not know', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
currentResult = ok(current({ status: 'starting', guestCount: 0 }));
|
||||
|
||||
await expect(host.readStatusAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export function isChildProcessAlive(child: ChildProcess): boolean {
|
||||
return child.exitCode === null && child.signalCode === null && !child.killed;
|
||||
/** Whether the process `pid` exists. EPERM means it does. */
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
return error?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any process in the group led by `pgid` exists. EPERM means it does. */
|
||||
@@ -18,7 +22,7 @@ export function isProcessGroupRunning(pgid: number): boolean {
|
||||
|
||||
/**
|
||||
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
|
||||
* children cannot survive after the parent is gone (e.g. across upterm redial).
|
||||
* children cannot survive after the parent is gone.
|
||||
*/
|
||||
export function killProcessGroup(
|
||||
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
|
||||
|
||||
@@ -4,12 +4,10 @@ import { graphql } from 'gql.tada';
|
||||
|
||||
import { formatSecondsForLog } from './formatDuration';
|
||||
import { sleepAsync } from './retry';
|
||||
import { SshConnectionConfig, startUptermHostAsync } from './upterm';
|
||||
import { SshConnectionConfig, UptermSessionStatus, startUptermHostAsync } from './upterm';
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
const MAX_SSH_REDIALS = 10;
|
||||
const REDIAL_BACKOFF_MS = 6_000;
|
||||
const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
|
||||
@@ -67,8 +65,8 @@ export function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string {
|
||||
}
|
||||
|
||||
export type SshSessionHandle = {
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
ensureConnectedAsync: () => Promise<void>;
|
||||
readStatusAsync: () => Promise<UptermSessionStatus>;
|
||||
reportReconnectingAsync: (reconnecting: boolean) => Promise<void>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -125,7 +123,7 @@ export async function startSshSessionAsync(
|
||||
try {
|
||||
({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: host.startedReconnecting },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -133,44 +131,27 @@ export async function startSshSessionAsync(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ensureConnectedAsync = async (): Promise<void> => {
|
||||
if (host.isAlive()) {
|
||||
let reportedReconnecting = host.startedReconnecting;
|
||||
const reportReconnectingAsync = async (reconnecting: boolean): Promise<void> => {
|
||||
if (reconnecting === reportedReconnecting) {
|
||||
return;
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
|
||||
try {
|
||||
if (!host.isAlive()) {
|
||||
logger.warn('The SSH relay connection dropped. Reconnecting...');
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: true },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}).catch(() => {});
|
||||
await host.redialAsync();
|
||||
}
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
});
|
||||
logger.info('The SSH relay connection was restored.');
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
|
||||
if (attempt < MAX_SSH_REDIALS) {
|
||||
await sleepAsync(REDIAL_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
});
|
||||
reportedReconnecting = reconnecting;
|
||||
} catch (err) {
|
||||
logger.debug({ err }, 'Failed to report the SSH relay connection state.');
|
||||
}
|
||||
throw new SystemError(
|
||||
`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
handle: {
|
||||
getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
|
||||
ensureConnectedAsync,
|
||||
readStatusAsync: () => host.readStatusAsync(),
|
||||
reportReconnectingAsync,
|
||||
stopAsync: () => host.stopAsync(),
|
||||
},
|
||||
idleTimeoutSeconds,
|
||||
@@ -191,25 +172,12 @@ export async function superviseSshSessionAsync({
|
||||
const idleTimeoutMs = idleTimeoutSeconds * 1_000;
|
||||
let idleSince: number | null = null;
|
||||
let previousClientCount = 0;
|
||||
let previousReconnecting = false;
|
||||
|
||||
for (;;) {
|
||||
let status: UptermSessionStatus;
|
||||
try {
|
||||
await handle.ensureConnectedAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
|
||||
Sentry.capture(
|
||||
'Could not restore the SSH relay connection',
|
||||
err instanceof Error ? err : undefined,
|
||||
{
|
||||
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let connectedClientCount: number;
|
||||
try {
|
||||
connectedClientCount = await handle.getConnectedClientCountAsync();
|
||||
status = await handle.readStatusAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
|
||||
Sentry.capture(
|
||||
@@ -221,6 +189,28 @@ export async function superviseSshSessionAsync({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status.state === 'gone') {
|
||||
logger.warn(
|
||||
`Could not restore the SSH relay connection (${status.reason}). Closing the session.`
|
||||
);
|
||||
Sentry.capture(`Could not restore the SSH relay connection (${status.reason})`, {
|
||||
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reconnecting = status.state === 'reconnecting';
|
||||
if (reconnecting !== previousReconnecting) {
|
||||
if (reconnecting) {
|
||||
logger.warn('The SSH relay connection dropped. Reconnecting...');
|
||||
} else {
|
||||
logger.info('The SSH relay connection was restored.');
|
||||
}
|
||||
previousReconnecting = reconnecting;
|
||||
}
|
||||
await handle.reportReconnectingAsync(reconnecting);
|
||||
|
||||
const { connectedClientCount } = status;
|
||||
if (connectedClientCount !== previousClientCount) {
|
||||
logger.info(`SSH clients connected: ${connectedClientCount}`);
|
||||
previousClientCount = connectedClientCount;
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { Env, SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProxiedDownloadUrl } from './download';
|
||||
import { isChildProcessAlive, killProcessGroup } from './processes';
|
||||
import { isProcessRunning } from './processes';
|
||||
import { sleepAsync } from './retry';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
||||
const BLOCKED_TUNNEL_REASONS = new Set([
|
||||
'agent_refused',
|
||||
'auth_refused',
|
||||
'proof_refused',
|
||||
'relay_key_changed',
|
||||
'relay_unsupported',
|
||||
]);
|
||||
const SESSION_ID_JSON_FIELD = /"sessionId"\s*:\s*"([^"]+)"/g;
|
||||
|
||||
export function resolveUptermGcsObjectName(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -32,9 +41,7 @@ export function resolveUptermGcsObjectName(
|
||||
const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
|
||||
const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
|
||||
const CONNECTION_POLL_INTERVAL_MS = 500;
|
||||
const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
||||
const CLIENT_COUNT_READ_ATTEMPTS = 4;
|
||||
const CLIENT_COUNT_READ_RETRY_MS = 500;
|
||||
|
||||
@@ -43,61 +50,47 @@ export type SshConnectionConfig = {
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type UptermSessionStatus =
|
||||
| { state: 'ready' | 'reconnecting'; connectedClientCount: number }
|
||||
| { state: 'gone'; reason: string };
|
||||
|
||||
export type UptermHost = {
|
||||
connectionConfig: SshConnectionConfig;
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
isAlive: () => boolean;
|
||||
redialAsync: () => Promise<SshConnectionConfig>;
|
||||
startedReconnecting: boolean;
|
||||
readStatusAsync: () => Promise<UptermSessionStatus>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
const UptermSessionJsonZ = z.object({
|
||||
const UptermStartedJsonZ = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
clientCount: z.number().optional(),
|
||||
adminSocket: z.string().min(1),
|
||||
pid: z.number().int().positive(),
|
||||
status: z.enum(['ready', 'reconnecting']),
|
||||
});
|
||||
|
||||
const UptermCurrentSessionJsonZ = z.object({
|
||||
guestCount: z.number(),
|
||||
status: z.string(),
|
||||
tunnelReason: z.string().optional(),
|
||||
});
|
||||
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
|
||||
|
||||
/**
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
|
||||
* prints the session id as the userinfo of the connect line it advertises
|
||||
* (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
|
||||
* same line. Control characters are dropped first so they cannot split a token mid-match. The
|
||||
* loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
|
||||
* blanks userinfo in any other URL as a catch-all.
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. The
|
||||
* session id is the secret: any id in a `sessionId` field of upterm's JSON output is removed
|
||||
* everywhere. Control characters are dropped first so they cannot split a token mid-match.
|
||||
*/
|
||||
export function redactConnectionSecrets(text: string): string {
|
||||
let redacted = text.replace(CONTROL_CHARACTERS, '');
|
||||
for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
|
||||
redacted = redacted.split(token).join('<redacted>');
|
||||
for (const [, id] of redacted.matchAll(SESSION_ID_JSON_FIELD)) {
|
||||
redacted = redacted.split(id).join('<redacted>');
|
||||
}
|
||||
return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function redactSpawnErrorForLog(err: unknown): unknown {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return err;
|
||||
}
|
||||
const spawnErr = err as { message?: unknown; stdout?: unknown; stderr?: unknown };
|
||||
return {
|
||||
...spawnErr,
|
||||
...(typeof spawnErr.message === 'string'
|
||||
? { message: redactConnectionSecrets(spawnErr.message) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stdout === 'string'
|
||||
? { stdout: redactConnectionSecrets(spawnErr.stdout) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stderr === 'string'
|
||||
? { stderr: redactConnectionSecrets(spawnErr.stderr) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveUptermPathAsync(env: Env, logger: bunyan): Promise<string> {
|
||||
try {
|
||||
await spawn('upterm', ['version'], { stdio: 'pipe', env });
|
||||
return 'upterm';
|
||||
} catch {}
|
||||
|
||||
export async function resolveUptermAsync(
|
||||
env: Env,
|
||||
logger: bunyan
|
||||
): Promise<{ uptermPath: string; downloadDir: string }> {
|
||||
const objectName = resolveUptermGcsObjectName();
|
||||
const downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-upterm-'));
|
||||
const uptermPath = path.join(downloadDir, objectName);
|
||||
@@ -115,7 +108,7 @@ export async function resolveUptermPathAsync(env: Env, logger: bunyan): Promise<
|
||||
if (proxiedUrl) {
|
||||
try {
|
||||
await downloadAsync(proxiedUrl);
|
||||
return uptermPath;
|
||||
return { uptermPath, downloadDir };
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
@@ -130,61 +123,60 @@ export async function resolveUptermPathAsync(env: Env, logger: bunyan): Promise<
|
||||
} catch (err) {
|
||||
await fs.rm(downloadDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new SystemError(
|
||||
`The upterm SSH client was not on PATH and could not be downloaded from ${directUrl}. ${
|
||||
`The upterm SSH client could not be downloaded from ${directUrl}. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
return uptermPath;
|
||||
return { uptermPath, downloadDir };
|
||||
}
|
||||
|
||||
async function findAdminSocketPathAsync(uptermSocketDir: string): Promise<string | null> {
|
||||
// Use this dial's own admin socket, not upterm's default, which can still point at a previous
|
||||
// dial's session after a redial and break client-count reads.
|
||||
const entries = await fs.readdir(uptermSocketDir).catch(() => [] as string[]);
|
||||
const socketName = entries.find(entry => entry.endsWith('.sock'));
|
||||
return socketName ? path.join(uptermSocketDir, socketName) : null;
|
||||
}
|
||||
|
||||
async function readCurrentSessionJsonAsync(
|
||||
uptermPath: string,
|
||||
adminSocketPath: string
|
||||
): Promise<UptermSessionJson | null> {
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
const result = await spawn(
|
||||
uptermPath,
|
||||
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
|
||||
return parsed.success ? parsed.data : null;
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionConfigAsync(
|
||||
function spawnOutput(err: unknown): string {
|
||||
const { stdout, stderr } = err as Partial<SpawnResult>;
|
||||
return redactConnectionSecrets(`${stderr ?? ''}${stdout ?? ''}`);
|
||||
}
|
||||
|
||||
async function readCurrentSessionStatusAsync(
|
||||
uptermPath: string,
|
||||
uptermSocketDir: string,
|
||||
getHostOutput: () => string
|
||||
): Promise<SshConnectionConfig> {
|
||||
const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
if (adminSocketPath) {
|
||||
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
|
||||
if (session) {
|
||||
return { type: 'upterm-v1', secret: session.sessionId };
|
||||
adminSocketPath: string,
|
||||
env: Env
|
||||
): Promise<UptermSessionStatus | null> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn(
|
||||
uptermPath,
|
||||
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
|
||||
{ stdio: 'pipe', env }
|
||||
));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = UptermCurrentSessionJsonZ.safeParse(parseJson(stdout));
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
const { status, guestCount, tunnelReason } = parsed.data;
|
||||
switch (status) {
|
||||
case 'reconnecting':
|
||||
if (tunnelReason && BLOCKED_TUNNEL_REASONS.has(tunnelReason)) {
|
||||
return { state: 'gone', reason: `${status} (${tunnelReason})` };
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SystemError(
|
||||
`The upterm client did not register with the relay within ${
|
||||
CONNECTION_STARTUP_TIMEOUT_MS / 1_000
|
||||
}s. Output:\n${redactConnectionSecrets(getHostOutput())}`
|
||||
);
|
||||
}
|
||||
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
|
||||
return { state: status, connectedClientCount: guestCount };
|
||||
case 'ready':
|
||||
return { state: status, connectedClientCount: guestCount };
|
||||
case 'disconnected':
|
||||
case 'ending':
|
||||
return { state: 'gone', reason: tunnelReason ? `${status} (${tunnelReason})` : status };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +184,13 @@ export async function startUptermHostAsync(
|
||||
ctx: BuildContext,
|
||||
{ relayServerUrl }: { relayServerUrl: string }
|
||||
): Promise<UptermHost> {
|
||||
const uptermPath = await resolveUptermPathAsync(ctx.env, ctx.logger);
|
||||
const { uptermPath, downloadDir } = await resolveUptermAsync(ctx.env, ctx.logger);
|
||||
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-ssh-'));
|
||||
const hostKeyPath = path.join(stateDir, 'id_host');
|
||||
const forceCommandPath = path.join(stateDir, 'join.sh');
|
||||
const uptermSocketDir = path.join(stateDir, 'upterm');
|
||||
const sessionName = `eas-${randomBytes(4).toString('hex').slice(0, 7)}`;
|
||||
const env: Env = { ...ctx.env, XDG_RUNTIME_DIR: stateDir };
|
||||
|
||||
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
|
||||
logger: ctx.logger,
|
||||
@@ -205,35 +198,81 @@ export async function startUptermHostAsync(
|
||||
const quotedBuildDirectory = `'${ctx.buildDirectory.replaceAll("'", "'\\''")}'`;
|
||||
await fs.writeFile(
|
||||
forceCommandPath,
|
||||
`#!/usr/bin/env bash\ncd -- ${quotedBuildDirectory}\nexec bash -l\n`,
|
||||
`#!/usr/bin/env bash\ncd -- ${quotedBuildDirectory}\nif [ -n "$SSH_ORIGINAL_COMMAND" ]; then\n exec bash -lc "$SSH_ORIGINAL_COMMAND"\nfi\nexec bash -l\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
let currentProcess: SpawnPromise<SpawnResult> | null = null;
|
||||
|
||||
const stopCurrentProcessAsync = async (): Promise<void> => {
|
||||
const previousProcess = currentProcess;
|
||||
currentProcess = null;
|
||||
if (!previousProcess) {
|
||||
return;
|
||||
const stopAsync = async (): Promise<void> => {
|
||||
try {
|
||||
await spawn(uptermPath, ['session', 'stop', sessionName], { stdio: 'pipe', env });
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to stop the upterm session. Output:\n${spawnOutput(err)}`);
|
||||
} finally {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
await fs.rm(downloadDir, { recursive: true, force: true });
|
||||
}
|
||||
killProcessGroup(previousProcess.child);
|
||||
await Promise.race([
|
||||
previousProcess.catch(() => {}),
|
||||
sleepAsync(PROCESS_EXIT_TIMEOUT_MS).then(() => {
|
||||
ctx.logger.debug('The previous upterm host process did not exit in time.');
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const getConnectedClientCountAsync = async (): Promise<number> => {
|
||||
let started: z.infer<typeof UptermStartedJsonZ>;
|
||||
try {
|
||||
ctx.logger.debug('Connecting to the SSH relay.');
|
||||
let stdout: string;
|
||||
try {
|
||||
// --force-command is what each connecting SSH client runs: the client's command, or a login
|
||||
// shell. The `sleep` after `--` is the session's own command, which keeps it up while nobody
|
||||
// is connected.
|
||||
({ stdout } = await spawn(
|
||||
uptermPath,
|
||||
[
|
||||
'host',
|
||||
'--server',
|
||||
relayServerUrl,
|
||||
'--accept',
|
||||
'--skip-host-key-check',
|
||||
'-i',
|
||||
hostKeyPath,
|
||||
'--force-command',
|
||||
forceCommandPath,
|
||||
'--detach',
|
||||
'--name',
|
||||
sessionName,
|
||||
'--output',
|
||||
'json',
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
|
||||
],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'], timeout: CONNECTION_STARTUP_TIMEOUT_MS }
|
||||
));
|
||||
} catch (err) {
|
||||
const failure = (err as Partial<SpawnResult>).signal
|
||||
? `did not register with the relay within ${CONNECTION_STARTUP_TIMEOUT_MS / 1_000}s`
|
||||
: 'could not register with the relay';
|
||||
throw new SystemError(`The upterm client ${failure}. Output:\n${spawnOutput(err)}`);
|
||||
}
|
||||
const parsed = UptermStartedJsonZ.safeParse(parseJson(stdout));
|
||||
if (!parsed.success) {
|
||||
throw new SystemError(
|
||||
`The upterm client did not report a usable session. Output:\n${redactConnectionSecrets(
|
||||
stdout
|
||||
)}`
|
||||
);
|
||||
}
|
||||
started = parsed.data;
|
||||
} catch (err) {
|
||||
await stopAsync().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const readStatusAsync = async (): Promise<UptermSessionStatus> => {
|
||||
for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
const session = adminSocketPath
|
||||
? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
|
||||
: null;
|
||||
if (session && typeof session.clientCount === 'number') {
|
||||
return session.clientCount;
|
||||
const status = await readCurrentSessionStatusAsync(uptermPath, started.adminSocket, env);
|
||||
if (status) {
|
||||
return status;
|
||||
}
|
||||
if (!isProcessRunning(started.pid)) {
|
||||
return { state: 'gone', reason: 'the upterm session process exited' };
|
||||
}
|
||||
if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
|
||||
await sleepAsync(CLIENT_COUNT_READ_RETRY_MS);
|
||||
@@ -244,79 +283,10 @@ export async function startUptermHostAsync(
|
||||
});
|
||||
};
|
||||
|
||||
const dialAsync = async (): Promise<SshConnectionConfig> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
|
||||
ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
|
||||
});
|
||||
|
||||
ctx.logger.debug('Connecting to the SSH relay.');
|
||||
// --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
|
||||
// `--` is the host-side process that keeps `upterm host` up while nobody is connected.
|
||||
const uptermProcess = spawn(
|
||||
uptermPath,
|
||||
[
|
||||
'host',
|
||||
'--server',
|
||||
relayServerUrl,
|
||||
'--accept',
|
||||
'--skip-host-key-check',
|
||||
'-i',
|
||||
hostKeyPath,
|
||||
'--force-command',
|
||||
forceCommandPath,
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
|
||||
],
|
||||
{
|
||||
// upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
|
||||
// find it for `session current` and clean it up on stop/redial.
|
||||
env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
uptermProcess.catch(err =>
|
||||
ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.')
|
||||
);
|
||||
uptermProcess.child.unref();
|
||||
currentProcess = uptermProcess;
|
||||
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
uptermProcess.child.stdout?.on('data', appendChunk);
|
||||
uptermProcess.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
|
||||
};
|
||||
|
||||
const stopAsync = async (): Promise<void> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
let connectionConfig: SshConnectionConfig;
|
||||
try {
|
||||
connectionConfig = await dialAsync();
|
||||
} catch (err) {
|
||||
await stopAsync();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
get connectionConfig() {
|
||||
return connectionConfig;
|
||||
},
|
||||
getConnectedClientCountAsync,
|
||||
isAlive: () => currentProcess != null && isChildProcessAlive(currentProcess.child),
|
||||
redialAsync: async () => {
|
||||
connectionConfig = await dialAsync();
|
||||
return connectionConfig;
|
||||
},
|
||||
connectionConfig: { type: 'upterm-v1', secret: started.sessionId },
|
||||
startedReconnecting: started.status === 'reconnecting',
|
||||
readStatusAsync,
|
||||
stopAsync,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.9.0"
|
||||
"@expo/steps": "27.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.9.0",
|
||||
"@expo/turtle-spawn": "27.10.0",
|
||||
"joi": "^17.13.9",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add `eas simulator:devices` to list the devices Expo suggests for a simulator session. ([#4879](https://github.com/expo/eas-cli/pull/4879) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include the parent CLI command in telemetry to distinguish nested commands from direct usage. ([#4876](https://github.com/expo/eas-cli/pull/4876) by [@davidmokos](https://github.com/davidmokos))
|
||||
|
||||
## 27.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+203
-154
File diff suppressed because it is too large.
Load diff
Generated
+32
@@ -9145,6 +9145,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "isDefault",
|
||||
"description": "Whether a session starts this device when it requests no android.deviceIdentifier.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -63742,6 +63758,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "isDefault",
|
||||
"description": "Whether a session starts this device when it requests no ios.deviceIdentifier.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/eas-json": "27.9.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/eas-json": "27.10.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.9.0",
|
||||
"@expo/logger": "27.10.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": "27.9.0",
|
||||
"@expo/steps": "27.10.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "22.23.4"
|
||||
"version": "22.23.5"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
|
||||
@@ -151,7 +151,9 @@ export async function isAnalyticsOptedOutAsync(): Promise<boolean> {
|
||||
/**
|
||||
* Create an instance of Analytics based on the user's analytics enabled preferences.
|
||||
*/
|
||||
export async function createAnalyticsAsync(): Promise<AnalyticsWithOrchestration> {
|
||||
export async function createAnalyticsAsync(
|
||||
parentCommand: string | null = null
|
||||
): Promise<AnalyticsWithOrchestration> {
|
||||
// TODO: remove after some time
|
||||
const amplitudeEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_AMPLITUDE_ENABLED, null);
|
||||
if (amplitudeEnabled !== null) {
|
||||
@@ -186,7 +188,7 @@ export async function createAnalyticsAsync(): Promise<AnalyticsWithOrchestration
|
||||
if (!persistedDeviceId) {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID, deviceId);
|
||||
}
|
||||
return new RudderstackAnalytics(deviceId);
|
||||
return new RudderstackAnalytics(deviceId, parentCommand);
|
||||
}
|
||||
|
||||
class NoOpAnalytics implements AnalyticsWithOrchestration {
|
||||
@@ -222,7 +224,10 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
|
||||
private identifiedActor: Actor | null = null;
|
||||
|
||||
constructor(private readonly persistentDeviceId: string) {
|
||||
constructor(
|
||||
private readonly persistentDeviceId: string,
|
||||
private readonly parentCommand: string | null
|
||||
) {
|
||||
// identify once with just anonymous ID. Once the actor is fetched, re-indentify with
|
||||
// both so that they can be associated
|
||||
this.rudderstackClient.identify({
|
||||
@@ -276,6 +281,7 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
...(this.parentCommand ? { parent_command: this.parentCommand } : {}),
|
||||
...(sandboxProvider ? { sandbox_provider: sandboxProvider } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
|
||||
@@ -123,6 +123,28 @@ it('adds detected sandbox context to analytics events', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the parent command when no command started this one', async () => {
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack.mock.calls[0][0].context).not.toHaveProperty('parent_command');
|
||||
});
|
||||
|
||||
it('adds the parent command to analytics events', async () => {
|
||||
const analytics = await createAnalyticsAsync('expo run:ios');
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
parent_command: 'expo run:ios',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe(isAnalyticsOptedOutAsync, () => {
|
||||
it('is false by default', async () => {
|
||||
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(false);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { markAsParentCommand } from '../parentCommand';
|
||||
|
||||
const originalParentCommand = process.env.__EXPO_PARENT_COMMAND;
|
||||
|
||||
function setInheritedCommand(inheritedCommand: string | undefined): void {
|
||||
if (inheritedCommand === undefined) {
|
||||
delete process.env.__EXPO_PARENT_COMMAND;
|
||||
} else {
|
||||
process.env.__EXPO_PARENT_COMMAND = inheritedCommand;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setInheritedCommand(originalParentCommand);
|
||||
});
|
||||
|
||||
it('returns the inherited parent command', () => {
|
||||
setInheritedCommand('expo run:ios');
|
||||
|
||||
expect(markAsParentCommand('eas build').parentCommand).toBe('expo run:ios');
|
||||
});
|
||||
|
||||
it.each([undefined, ''])(
|
||||
'returns no parent command when the inherited value is %p',
|
||||
inheritedCommand => {
|
||||
setInheritedCommand(inheritedCommand);
|
||||
|
||||
expect(markAsParentCommand('eas build').parentCommand).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([undefined, '', 'expo run:ios'])(
|
||||
'marks the command until the inherited value %p is restored',
|
||||
inheritedCommand => {
|
||||
setInheritedCommand(inheritedCommand);
|
||||
|
||||
const { restoreParentCommand } = markAsParentCommand('eas build');
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe('eas build');
|
||||
|
||||
restoreParentCommand();
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe(inheritedCommand);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Makes child processes and nested EAS commands report `command` as their parent command.
|
||||
* Expo CLI uses the same variable name and the same `<cli> <command>` value. Do not change them.
|
||||
*
|
||||
* `parentCommand` is the Expo CLI or EAS CLI command that started the current one.
|
||||
* `restoreParentCommand` puts the inherited value back.
|
||||
*/
|
||||
export function markAsParentCommand(command: string): {
|
||||
parentCommand: string | null;
|
||||
restoreParentCommand: () => void;
|
||||
} {
|
||||
const inheritedCommand = process.env.__EXPO_PARENT_COMMAND;
|
||||
process.env.__EXPO_PARENT_COMMAND = command;
|
||||
|
||||
return {
|
||||
parentCommand: inheritedCommand || null,
|
||||
restoreParentCommand: () => {
|
||||
if (inheritedCommand === undefined) {
|
||||
delete process.env.__EXPO_PARENT_COMMAND;
|
||||
} else {
|
||||
process.env.__EXPO_PARENT_COMMAND = inheritedCommand;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import { markAsParentCommand } from '../analytics/parentCommand';
|
||||
import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
@@ -238,26 +239,33 @@ export default abstract class EasCommand extends Command {
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
async run(): Promise<any> {
|
||||
this.analyticsInternal = await createAnalyticsAsync();
|
||||
this.sessionManagerInternal = new SessionManager(this.analytics);
|
||||
// id is assigned by oclif in constructor based on the filepath:
|
||||
// commands/submit === submit, commands/build/list === build:list
|
||||
const command = `eas ${this.id}`;
|
||||
const { parentCommand, restoreParentCommand } = markAsParentCommand(command);
|
||||
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
const actor = await this.sessionManager.getUserAsync();
|
||||
if (actor) {
|
||||
Sentry.setUser({
|
||||
id: actor.id,
|
||||
username: getActorDisplayName(actor),
|
||||
});
|
||||
try {
|
||||
this.analyticsInternal = await createAnalyticsAsync(parentCommand);
|
||||
this.sessionManagerInternal = new SessionManager(this.analytics);
|
||||
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
const actor = await this.sessionManager.getUserAsync();
|
||||
if (actor) {
|
||||
Sentry.setUser({
|
||||
id: actor.id,
|
||||
username: getActorDisplayName(actor),
|
||||
});
|
||||
}
|
||||
|
||||
this.analytics.logEvent(CommandEvent.ACTION, { action: command });
|
||||
|
||||
return await this.runAsync();
|
||||
} finally {
|
||||
// Commands can run inside each other, for example `eas open` runs `eas browse`.
|
||||
// The outer command is the parent command again after the inner one ends.
|
||||
restoreParentCommand();
|
||||
}
|
||||
|
||||
this.analytics.logEvent(CommandEvent.ACTION, {
|
||||
// id is assigned by oclif in constructor based on the filepath:
|
||||
// commands/submit === submit, commands/build/list === build:list
|
||||
action: `eas ${this.id}`,
|
||||
});
|
||||
|
||||
return await this.runAsync();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
|
||||
@@ -83,6 +83,52 @@ const createTestEasCommand = (): any => {
|
||||
// instance and calling run() like other command tests, because they cover
|
||||
// oclif's catch and finally wiring, which only runs inside the static path.
|
||||
describe('EasCommand', () => {
|
||||
describe('parent command', () => {
|
||||
const originalParentCommand = process.env.__EXPO_PARENT_COMMAND;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalParentCommand === undefined) {
|
||||
delete process.env.__EXPO_PARENT_COMMAND;
|
||||
} else {
|
||||
process.env.__EXPO_PARENT_COMMAND = originalParentCommand;
|
||||
}
|
||||
});
|
||||
|
||||
it('is the parent of a command that it runs, then restores the inherited parent', async () => {
|
||||
process.env.__EXPO_PARENT_COMMAND = 'expo run:ios';
|
||||
const ParentCommand = createTestEasCommand();
|
||||
const ChildCommand = createTestEasCommand();
|
||||
ParentCommand.id = 'build';
|
||||
ChildCommand.id = 'update:configure';
|
||||
jest.spyOn(ParentCommand.prototype, 'runAsync').mockImplementation(async () => {
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe('eas build');
|
||||
await ChildCommand.run([], mockConfig);
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe('eas build');
|
||||
});
|
||||
|
||||
await ParentCommand.run([], mockConfig);
|
||||
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
expect(createAnalyticsAsync).toHaveBeenNthCalledWith(1, 'expo run:ios');
|
||||
expect(createAnalyticsAsync).toHaveBeenNthCalledWith(2, 'eas build');
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe('expo run:ios');
|
||||
});
|
||||
|
||||
it('restores the inherited parent after the command fails', async () => {
|
||||
process.env.__EXPO_PARENT_COMMAND = 'expo run:ios';
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
jest
|
||||
.spyOn(TestEasCommand.prototype, 'runAsync')
|
||||
.mockRejectedValue(new Error('Command failed'));
|
||||
|
||||
await expect(TestEasCommand.run([], mockConfig)).rejects.toThrow(
|
||||
'testEasCommand command failed.'
|
||||
);
|
||||
|
||||
expect(process.env.__EXPO_PARENT_COMMAND).toBe('expo run:ios');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without exceptions', () => {
|
||||
it('ensures the user data is read', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionProfilesQuery } from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorDevices from '../devices';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('chalk', () => ({
|
||||
__esModule: true,
|
||||
default: { bold: (text: string) => text, dim: (text: string) => text },
|
||||
}));
|
||||
|
||||
const mockProfilesAsync = jest.mocked(DeviceRunSessionQuery.profilesAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
const mockLog = jest.mocked(Log.log);
|
||||
|
||||
const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] = {
|
||||
__typename: 'DeviceRunSessionProfiles',
|
||||
ios: {
|
||||
__typename: 'IosDeviceRunSessionProfiles',
|
||||
devices: [
|
||||
{
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17',
|
||||
displayName: 'iPhone 17',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17 Pro',
|
||||
displayName: 'iPhone 17 Pro',
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
__typename: 'AndroidDeviceRunSessionProfiles',
|
||||
devices: [
|
||||
{
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'medium_phone',
|
||||
displayName: 'Medium Phone',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'pixel_9',
|
||||
displayName: 'Pixel 9',
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const iosDevices = [
|
||||
{ identifier: 'iPhone 17', displayName: 'iPhone 17', isDefault: true },
|
||||
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro', isDefault: false },
|
||||
];
|
||||
const androidDevices = [
|
||||
{ identifier: 'medium_phone', displayName: 'Medium Phone', isDefault: true },
|
||||
{ identifier: 'pixel_9', displayName: 'Pixel 9', isDefault: false },
|
||||
];
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorDevices, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockProfilesAsync.mockResolvedValue(profiles);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorDevices {
|
||||
const command = new SimulatorDevices(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('does not need a project', () => {
|
||||
expect(Object.keys(SimulatorDevices.contextDefinition)).toEqual(['loggedIn']);
|
||||
});
|
||||
|
||||
it('logs the devices of both platforms', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'iOS iPhone 17 (default) "iPhone 17"',
|
||||
'iOS iPhone 17 Pro "iPhone 17 Pro"',
|
||||
'Android Medium Phone (default) medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'Start a session with: eas simulator:start --platform ios --device "iPhone 17"'
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the devices of one platform with --platform', async () => {
|
||||
await createCommand(['--platform', 'android']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'Android Medium Phone (default) medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'Start a session with: eas simulator:start --platform android --device medium_phone'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the default flag from the API instead of the device name or list order', async () => {
|
||||
mockProfilesAsync.mockResolvedValue({
|
||||
...profiles,
|
||||
ios: {
|
||||
...profiles.ios,
|
||||
devices: profiles.ios.devices.map(device => ({
|
||||
...device,
|
||||
isDefault: device.identifier === 'iPhone 17 Pro',
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await createCommand(['--platform', 'ios']).runAsync();
|
||||
|
||||
const table = mockLog.mock.calls[0][0];
|
||||
expect(table).toContain('iPhone 17 Pro (default)');
|
||||
expect(table).not.toContain('iPhone 17 (default)');
|
||||
expect(table).toContain('"iPhone 17 Pro"');
|
||||
});
|
||||
|
||||
it('says so when the service lists no devices', async () => {
|
||||
mockProfilesAsync.mockResolvedValue({
|
||||
...profiles,
|
||||
ios: { ...profiles.ios, devices: [] },
|
||||
android: { ...profiles.android, devices: [] },
|
||||
});
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockLog).toHaveBeenCalledWith('No simulator devices found.');
|
||||
});
|
||||
|
||||
it('emits JSON for both platforms', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
ios: iosDevices,
|
||||
android: androidDevices,
|
||||
});
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON for one platform with --platform', async () => {
|
||||
await createCommand(['--json', '-p', 'ios']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ ios: iosDevices });
|
||||
});
|
||||
|
||||
it('propagates a query failure', async () => {
|
||||
mockProfilesAsync.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { appPlatformDisplayNames } from '../../platform';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
} from '../../simulator/utils';
|
||||
|
||||
const PLATFORM_ORDER: readonly DeviceRunSessionPlatformFlagValue[] = ['ios', 'android'];
|
||||
|
||||
function formatDeviceFlagValue(identifier: string): string {
|
||||
return /^[\w-]+$/.test(identifier) ? identifier : JSON.stringify(identifier);
|
||||
}
|
||||
|
||||
export default class SimulatorDevices extends EasCommand {
|
||||
static override aliases = getSimulatorCommandAliases('devices');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('devices');
|
||||
static override description = 'list the devices Expo suggests for a remote simulator session';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Show the devices of one platform',
|
||||
options: DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorDevices);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorDevices, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const fetchSpinner = jsonFlag ? null : ora('Fetching simulator devices').start();
|
||||
let profiles;
|
||||
try {
|
||||
profiles = await DeviceRunSessionQuery.profilesAsync(graphqlClient);
|
||||
fetchSpinner?.stop();
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to fetch simulator devices');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const platforms = PLATFORM_ORDER.filter(
|
||||
platform => !flags.platform || flags.platform === platform
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(
|
||||
Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
|
||||
identifier,
|
||||
displayName,
|
||||
isDefault,
|
||||
})),
|
||||
])
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = platforms.flatMap(platform =>
|
||||
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
|
||||
platform,
|
||||
displayName: isDefault ? `${displayName} (default)` : displayName,
|
||||
flagValue: formatDeviceFlagValue(identifier),
|
||||
}))
|
||||
);
|
||||
if (devices.length === 0) {
|
||||
Log.log('No simulator devices found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const header = ['Platform', 'Device', '--device value'];
|
||||
const rows = devices.map(({ platform, displayName, flagValue }) => [
|
||||
appPlatformDisplayNames[DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE[platform]],
|
||||
displayName,
|
||||
flagValue,
|
||||
]);
|
||||
const widths = header.map((title, column) =>
|
||||
Math.max(title.length, ...rows.map(row => row[column].length))
|
||||
);
|
||||
const formatRow = (row: string[]): string =>
|
||||
row
|
||||
.map((cell, column) => cell.padEnd(widths[column]))
|
||||
.join(' ')
|
||||
.trimEnd();
|
||||
Log.log([chalk.dim(formatRow(header)), ...rows.map(formatRow)].join('\n'));
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Start a session with: eas simulator:start --platform ${devices[0].platform} --device ${devices[0].flagValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,11 +63,14 @@ import {
|
||||
simulatorRequestProperties,
|
||||
} from '../../simulator/requestAnalytics';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
@@ -88,13 +91,7 @@ const OUT_CONFIG_TYPE_VALUES = {
|
||||
Env: 'env',
|
||||
Dotenv: 'dotenv',
|
||||
} as const;
|
||||
const PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
type PlatformFlagValue = (typeof PLATFORM_FLAG_VALUES)[number];
|
||||
const EGRESS_FLAG_VALUES = ['local'] as const;
|
||||
const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
export default class Simulator extends EasCommand {
|
||||
static override aliases = ['simulator:start', ...getSimulatorCommandAliases('start')];
|
||||
@@ -131,7 +128,7 @@ With local egress, actions wait for the connection and require interactive mode.
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
options: PLATFORM_FLAG_VALUES,
|
||||
options: DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
})(),
|
||||
name: Flags.string({
|
||||
description:
|
||||
@@ -144,7 +141,7 @@ With local egress, actions wait for the connection and require interactive mode.
|
||||
}),
|
||||
device: Flags.string({
|
||||
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.',
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 17 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_9"). Defaults to a device chosen by the runner. eas simulator:devices lists the devices Expo suggests.',
|
||||
}),
|
||||
'system-image': Flags.string({
|
||||
description:
|
||||
@@ -897,11 +894,11 @@ async function formatJobRunErrorsAsync(
|
||||
}
|
||||
|
||||
async function resolvePlatformAsync(
|
||||
platform: PlatformFlagValue | undefined,
|
||||
platform: DeviceRunSessionPlatformFlagValue | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<AppPlatform> {
|
||||
if (platform) {
|
||||
return APP_PLATFORM_BY_FLAG_VALUE[platform];
|
||||
return DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE[platform];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
|
||||
+9
@@ -1503,6 +1503,8 @@ export type AndroidDeviceRunSessionDevice = {
|
||||
displayName: Scalars['String']['output'];
|
||||
/** AVD hardware profile id accepted by android.deviceIdentifier (e.g. "pixel_9"). */
|
||||
identifier: Scalars['String']['output'];
|
||||
/** Whether a session starts this device when it requests no android.deviceIdentifier. */
|
||||
isDefault: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type AndroidDeviceRunSessionProfiles = {
|
||||
@@ -9142,6 +9144,8 @@ export type IosDeviceRunSessionDevice = {
|
||||
displayName: Scalars['String']['output'];
|
||||
/** Simulator device name accepted by ios.deviceIdentifier (e.g. "iPhone 17 Pro"). */
|
||||
identifier: Scalars['String']['output'];
|
||||
/** Whether a session starts this device when it requests no ios.deviceIdentifier. */
|
||||
isDefault: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type IosDeviceRunSessionProfiles = {
|
||||
@@ -16432,6 +16436,11 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionProfilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> } } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionJobRunErrorsByIdQuery,
|
||||
DeviceRunSessionJobRunErrorsByIdQueryVariables,
|
||||
DeviceRunSessionProfilesQuery,
|
||||
DeviceRunSessionProfilesQueryVariables,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
DeviceRunSessionsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -200,6 +202,41 @@ export const DeviceRunSessionQuery = {
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
async profilesAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionProfilesQuery, DeviceRunSessionProfilesQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionProfilesQuery {
|
||||
deviceRunSessions {
|
||||
profiles {
|
||||
ios {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
isDefault
|
||||
}
|
||||
}
|
||||
android {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
isDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.profiles;
|
||||
},
|
||||
async listByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SimulatorAvailability from '../../commands/simulator/availability';
|
||||
import SimulatorControl from '../../commands/simulator/control';
|
||||
import SimulatorDevices from '../../commands/simulator/devices';
|
||||
import SimulatorEgress from '../../commands/simulator/egress';
|
||||
import SimulatorEvents from '../../commands/simulator/events';
|
||||
import SimulatorExec from '../../commands/simulator/exec';
|
||||
@@ -14,6 +15,7 @@ describe('simulator command aliases', () => {
|
||||
it.each([
|
||||
['availability', SimulatorAvailability],
|
||||
['control', SimulatorControl],
|
||||
['devices', SimulatorDevices],
|
||||
['egress', SimulatorEgress],
|
||||
['events', SimulatorEvents],
|
||||
['exec', SimulatorExec],
|
||||
|
||||
@@ -56,6 +56,17 @@ export const DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS = [
|
||||
...new Set(Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES)),
|
||||
];
|
||||
|
||||
export const DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
export type DeviceRunSessionPlatformFlagValue =
|
||||
(typeof DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES)[number];
|
||||
export const DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE: Record<
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
AppPlatform
|
||||
> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
// Every enum value behind a flag value, so filtering by web-preview-only also
|
||||
// matches sessions created with the legacy ServeSim type.
|
||||
export function deviceRunSessionTypesForFlagValue(value: string): DeviceRunSessionType[] {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/build-tools": "27.10.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.9.0",
|
||||
"@expo/logger": "27.9.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.9.0",
|
||||
"@expo/turtle-spawn": "27.10.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/logger": "27.9.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.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": "27.9.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/vm-tools
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/vm-tools",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"description": "VM startup script loader",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.10.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "27.9.0",
|
||||
"version": "27.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "27.9.0",
|
||||
"@expo/build-tools": "27.10.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "27.9.0",
|
||||
"@expo/logger": "27.9.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.9.0",
|
||||
"@expo/template-file": "27.9.0",
|
||||
"@expo/turtle-spawn": "27.9.0",
|
||||
"@expo/steps": "27.10.0",
|
||||
"@expo/template-file": "27.10.0",
|
||||
"@expo/turtle-spawn": "27.10.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -48,15 +48,15 @@ function endPhaseCalls(logger: any): any[] {
|
||||
|
||||
describe(startSshSessionPhaseAsync, () => {
|
||||
let handle: {
|
||||
getConnectedClientCountAsync: jest.Mock;
|
||||
ensureConnectedAsync: jest.Mock;
|
||||
readStatusAsync: jest.Mock;
|
||||
reportReconnectingAsync: jest.Mock;
|
||||
stopAsync: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handle = {
|
||||
getConnectedClientCountAsync: jest.fn(async () => 0),
|
||||
ensureConnectedAsync: jest.fn(async () => {}),
|
||||
readStatusAsync: jest.fn(async () => ({ state: 'ready', connectedClientCount: 0 })),
|
||||
reportReconnectingAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
mocked.startSshSessionAsync.mockResolvedValue({ handle, idleTimeoutSeconds: 0 } as any);
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:27.9.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:27.10.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/downloader": "npm:27.10.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.9.0"
|
||||
"@expo/template-file": "npm:27.9.0"
|
||||
"@expo/turtle-spawn": "npm:27.9.0"
|
||||
"@expo/steps": "npm:27.10.0"
|
||||
"@expo/template-file": "npm:27.10.0"
|
||||
"@expo/turtle-spawn": "npm:27.10.0"
|
||||
"@expo/xcpretty": "npm:^4.4.7"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:27.9.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:27.10.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:27.9.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:27.10.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:27.9.0"
|
||||
"@expo/turtle-spawn": "npm:27.10.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:27.9.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:27.10.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:27.9.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:27.10.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3149,12 +3149,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:27.9.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:27.10.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3180,7 +3180,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:27.9.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:27.10.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3203,11 +3203,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:27.9.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:27.10.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3238,17 +3238,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:27.9.0"
|
||||
"@expo/build-tools": "npm:27.10.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.9.0"
|
||||
"@expo/template-file": "npm:27.9.0"
|
||||
"@expo/turtle-spawn": "npm:27.9.0"
|
||||
"@expo/steps": "npm:27.10.0"
|
||||
"@expo/template-file": "npm:27.10.0"
|
||||
"@expo/turtle-spawn": "npm:27.10.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10123,7 +10123,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:27.9.0"
|
||||
"@expo/steps": "npm:27.10.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10707,12 +10707,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:27.9.0"
|
||||
"@expo/build-tools": "npm:27.10.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:27.9.0"
|
||||
"@expo/turtle-spawn": "npm:27.10.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10766,12 +10766,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:27.9.0"
|
||||
"@expo/eas-json": "npm:27.9.0"
|
||||
"@expo/eas-build-job": "npm:27.10.0"
|
||||
"@expo/eas-json": "npm:27.10.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:27.9.0"
|
||||
"@expo/logger": "npm:27.10.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10783,7 +10783,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.9.0"
|
||||
"@expo/steps": "npm:27.10.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user