mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7819779519 | ||
|
|
1eee88fc8f | ||
|
|
ab50bdf55a | ||
|
|
ec9896ae23 | ||
|
|
21a16849c1 | ||
|
|
8501f3e7d8 | ||
|
|
eedff0c99d | ||
|
|
c7e9fe56de | ||
|
|
b8505a90f1 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an agent-device session that is still starting up when it is stopped.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Appium session that is still starting up when it is stopped.
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Argent session that is still starting up when it is stopped.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop a web-preview session that is still starting up when it is stopped.
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- [build-tools] Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [build-tools][worker] Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Extract the worker API client core from the device run session worker API client. ([#4806](https://github.com/expo/eas-cli/pull/4806) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.4.1",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/downloader": "27.5.0",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/logger": "27.5.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.4.1",
|
||||
"@expo/template-file": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/template-file": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
@@ -31,6 +32,34 @@ describe('BuildContext', () => {
|
||||
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('keeps the sandbox worker token out of the env and keeps the other secrets', () => {
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
value: 'sandbox-worker-token',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
{ name: 'KEEP', value: 'value', type: EnvironmentSecretType.STRING },
|
||||
],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(ctx.env.EAS_SANDBOX_WORKER_TOKEN).toBeUndefined();
|
||||
expect(ctx.env.KEEP).toBe('value');
|
||||
});
|
||||
|
||||
it('should merge secrets', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
@@ -361,6 +361,67 @@ describe('Datadog singleton', () => {
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the sandbox worker API with the sandbox worker token', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/sandbox-worker/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.workflow.hook',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { hook: 'on_start', target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-token' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('labels metric upload failures for sandbox targets with the sandbox kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle sandbox metric',
|
||||
expect.any(Error),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report logs for sandbox targets', () => {
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.log('Custom build saw user-provided function');
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
@@ -116,6 +117,73 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches a sandbox project source from the sandbox worker router with only the worker token', async () => {
|
||||
const gitCommitHash = randomBytes(20).toString('hex');
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
secrets: {
|
||||
robotAccessToken: 'sandbox-worker-token',
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'sandbox-worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EAS_BUILD_ID: randomUUID(),
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
type: ArchiveSourceType.GIT,
|
||||
},
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.dev/v2/sandbox-worker/project-archive',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-worker-token' },
|
||||
})
|
||||
);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: expect.objectContaining({
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ArchiveSourceType } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ProjectArchiveSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]);
|
||||
|
||||
export type ProjectArchiveSource = z.infer<typeof ProjectArchiveSourceSchema>;
|
||||
@@ -10,7 +10,9 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { shallowCloneRepositoryAsync } from './git';
|
||||
import { type ProjectArchiveSource, ProjectArchiveSourceSchema } from './projectArchiveSource';
|
||||
import { BuildContext } from '../context';
|
||||
import { getSandboxWorkerApi } from '../steps/utils/sandboxWorkerApi';
|
||||
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
@@ -245,7 +247,18 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
async function fetchProjectArchiveSourceAsync(
|
||||
ctx: BuildContext<Job>
|
||||
): Promise<ProjectArchiveSource> {
|
||||
const sandboxWorkerApi = getSandboxWorkerApi(ctx);
|
||||
if (sandboxWorkerApi) {
|
||||
return await sandboxWorkerApi.getProjectArchiveSourceAsync({
|
||||
timeoutMs: 20000,
|
||||
retries: 3,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -283,20 +296,7 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
const dataResult = z.object({ data: ProjectArchiveSourceSchema }).safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
type EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
Job,
|
||||
LogMarker,
|
||||
@@ -18,6 +19,7 @@ import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { SANDBOX_WORKER_TOKEN_ENV } from './steps/utils/sandboxWorkerApi';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -408,7 +410,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
|
||||
|
||||
const environmentSecrets: Record<string, string> = {};
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets) {
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets.filter(isJobSecret)) {
|
||||
if (type === EnvironmentSecretType.STRING) {
|
||||
environmentSecrets[name] = value;
|
||||
} else {
|
||||
@@ -422,3 +424,9 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return environmentSecrets;
|
||||
}
|
||||
}
|
||||
|
||||
const EXCLUDED_JOB_SECRET_NAMES: ReadonlySet<string> = new Set([SANDBOX_WORKER_TOKEN_ENV]);
|
||||
|
||||
function isJobSecret({ name }: EnvironmentSecret): boolean {
|
||||
return !EXCLUDED_JOB_SECRET_NAMES.has(name);
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
import { SANDBOX_WORKER_TOKEN_HEADER } from './steps/utils/sandboxWorkerApi';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string }
|
||||
// A device run session job with a session token. The token names the session and its job run.
|
||||
| { kind: 'deviceRunSession' };
|
||||
| { kind: 'deviceRunSession' }
|
||||
| { kind: 'sandbox' };
|
||||
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' | 'sandbox' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
sandboxWorkerToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'sandbox' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
@@ -41,6 +48,12 @@ function getMetricsRequest(options: DatadogSetupOptions): {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
|
||||
};
|
||||
}
|
||||
if ('sandboxWorkerToken' in options) {
|
||||
return {
|
||||
path: 'sandbox-worker/metrics',
|
||||
headers: { [SANDBOX_WORKER_TOKEN_HEADER]: options.sandboxWorkerToken },
|
||||
};
|
||||
}
|
||||
const { target, robotAccessToken } = options;
|
||||
return {
|
||||
path:
|
||||
|
||||
@@ -43,5 +43,6 @@ export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
export { getSandboxWorkerTokenFromEnvironmentSecrets } from './steps/utils/sandboxWorkerApi';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
@@ -1,11 +1,35 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { EnvironmentSecretType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
|
||||
import { createStartSandboxBuildFunction } from '../startSandbox';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const context = {
|
||||
job: {
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
};
|
||||
const inputs = { sandbox_id: { value: 'sandbox-id' } };
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
@@ -16,49 +40,41 @@ describe('sandbox build functions', () => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
|
||||
const markReady = jest
|
||||
.spyOn(SandboxWorkerApi.prototype, 'markReadyAsync')
|
||||
.mockResolvedValue(undefined);
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(markReady).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(markReady).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -67,177 +83,186 @@ describe('sandbox build functions', () => {
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('excludes the MCP token from the command environment', async () => {
|
||||
const env = {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'token',
|
||||
};
|
||||
it('connects with the worker token from the job secrets, which the step env does not have', async () => {
|
||||
const env = { PREPARED_BY_EARLIER_STEP: 'value' };
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env,
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'worker-token', env });
|
||||
});
|
||||
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {}, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the sandbox ready with only the worker token and aborts the request when canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
jest.mocked(fetch).mockImplementationOnce(async (url, init) => {
|
||||
expect(url).toBe('https://api.expo.test/v2/sandbox-worker/ready');
|
||||
expect(init?.headers).toEqual({ 'expo-sandbox-worker-token': 'worker-token' });
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(new AbortError('Aborted')));
|
||||
requestStarted();
|
||||
});
|
||||
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
it('throws a system error and stops the daemon when readiness fails', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
jest.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'Sandbox has stopped.' }] }), {
|
||||
status: 400,
|
||||
})
|
||||
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
|
||||
);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
const result = fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
message: 'Failed to mark sandbox sandbox-id as ready.',
|
||||
cause: expect.objectContaining({ status: 400 }),
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
it('falls back to the legacy token and keeps the Expo token for commands without a worker token', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({ ...context, job: {} } as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'legacy-token',
|
||||
},
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'legacy-token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the sandbox ready over GraphQL without a worker token', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {},
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
await fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: 0.0001 } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'legacy-token' },
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
it.each([
|
||||
{
|
||||
missing: 'a sandbox worker token or a legacy token',
|
||||
overrides: { job: {} },
|
||||
message: 'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN.',
|
||||
},
|
||||
{
|
||||
missing: 'the MCP server URL',
|
||||
overrides: { mcpServerUrl: undefined },
|
||||
message: 'MCP server URL is required to start the sandbox daemon.',
|
||||
},
|
||||
{
|
||||
missing: 'the API server URL',
|
||||
overrides: { expoApiV2BaseUrl: undefined },
|
||||
message: 'The API server URL is not set',
|
||||
},
|
||||
])('throws a system error without $missing', async ({ overrides, message }) => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({ ...context, ...overrides } as any);
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
const result = fn.fn!({} as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toThrow(message);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,9 +35,9 @@ import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../utils/workerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -219,7 +219,7 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
isRetryable: error => error instanceof WorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { getLegacySandboxConnection } from '../utils/legacySandboxApi';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
import { getSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
@@ -39,16 +29,22 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
const { sandboxApi, credential, commandEnv } = workerApi
|
||||
? {
|
||||
sandboxApi: workerApi,
|
||||
credential: workerApi.token,
|
||||
commandEnv: env,
|
||||
}
|
||||
: getLegacySandboxConnection({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
env,
|
||||
sandboxId: String(inputs.sandbox_id.value),
|
||||
});
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
@@ -62,20 +58,25 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
credential,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
workerApi: sandboxApi,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
await sandboxApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
|
||||
{ cause: error }
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
@@ -103,23 +104,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
|
||||
@@ -9,10 +9,8 @@ import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -577,7 +575,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
cause: expect.any(WorkerApiError),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -589,7 +587,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
cause: expect.any(WorkerApiError),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
@@ -65,23 +63,6 @@ describe(getDeviceRunSessionWorkerApi, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const api = getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
|
||||
} as unknown as CustomBuildContext);
|
||||
|
||||
await api!.getSessionAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/session',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when the token is set but no API URL is known', () => {
|
||||
expect(() =>
|
||||
getDeviceRunSessionWorkerApi({
|
||||
@@ -268,273 +249,4 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await createApi().fetchTurnIceServersAsync({
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.stopAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe(
|
||||
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session worker API retries', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
'recovers from HTTP %s when starting a session',
|
||||
async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const result = createApi().startAsync(
|
||||
{ remoteConfig: { url: 'https://preview.test' } },
|
||||
{ retries: 3, timeoutMs: 30_000 }
|
||||
);
|
||||
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
|
||||
jest.mocked(turtleFetch).mock.calls[0]
|
||||
);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('recovers from a network failure', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
|
||||
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
|
||||
build: null,
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('exhausts three retries with exponential backoff', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
|
||||
await expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({ status });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
|
||||
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
|
||||
[
|
||||
'screenshot application metadata',
|
||||
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
|
||||
],
|
||||
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
|
||||
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
|
||||
[
|
||||
'TURN without an explicit retry option',
|
||||
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
],
|
||||
])('does not retry %s by default', async (_name, call) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit retry count', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().fetchTurnIceServersAsync({ retries: 1 })
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not send another request after cancellation during backoff', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
controller.abort();
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session retry safety', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('does not replay artifact creation after a transient failure', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(
|
||||
createApi().createArtifactUploadSessionAsync({
|
||||
name: 'report',
|
||||
filename: 'report.json',
|
||||
size: 1,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send an already cancelled request', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['system', 'body-timeout'])(
|
||||
'retries a response body transport failure (%s)',
|
||||
async type => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const response = jsonResponse({});
|
||||
jest
|
||||
.spyOn(response, 'json')
|
||||
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).resolves.toMatchObject({ status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
|
||||
import { LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
describe(LegacySandboxApi, () => {
|
||||
let mutation: jest.Mock;
|
||||
let api: LegacySandboxApi;
|
||||
|
||||
beforeEach(() => {
|
||||
mutation = jest.fn();
|
||||
api = new LegacySandboxApi({ mutation } as unknown as Client, 'sandbox-id');
|
||||
});
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
}
|
||||
}
|
||||
|
||||
it('marks the sandbox ready and rethrows a GraphQL error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: ['Sandbox has stopped.'] });
|
||||
mockMutationResults({ data: {} }, { error });
|
||||
|
||||
await api.markReadyAsync();
|
||||
await expect(api.markReadyAsync()).rejects.toBe(error);
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an upload session for the sandbox', async () => {
|
||||
mockMutationResults({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
).resolves.toEqual({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ codes: ['VALIDATION_ERROR'], code: 'VALIDATION_ERROR' },
|
||||
{ codes: ['VALIDATION_ERROR', 'UNEXPECTED_ERROR'], code: undefined },
|
||||
])(
|
||||
'reports upload session errors with codes $codes as a worker API error with code $code',
|
||||
async ({ codes, code }) => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: codes.map((errorCode, index) => ({
|
||||
message: `Error ${index}.`,
|
||||
extensions: { errorCode },
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.message).toBe(codes.map((_, index) => `Error ${index}.`).join('\n'));
|
||||
}
|
||||
);
|
||||
|
||||
it('retries finalize after a server error and throws a failure that persists', async () => {
|
||||
const persistent = new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
});
|
||||
mockMutationResults(
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{ error: persistent }
|
||||
);
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toBe(persistent);
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a finalized artifact without a download URL', async () => {
|
||||
mockMutationResults({
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
});
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toThrow(
|
||||
`Finalized sandbox artifact ${ARTIFACT_ID} has no download URL.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,8 @@ jest.mock('node-fetch', () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
|
||||
import { SandboxDaemonErrorCode } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import fetch, { type RequestInit } from 'node-fetch';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
@@ -24,6 +23,9 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const { AbortError, FetchError, Response } = jest.requireActual(
|
||||
'node-fetch'
|
||||
@@ -33,25 +35,18 @@ const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
const SIGNED_URL = 'https://uploads.expo.test/artifact?X-Amz-Signature=secret-signature';
|
||||
const DOWNLOAD_URL = 'https://downloads.expo.test/artifact?X-Amz-Signature=download-signature';
|
||||
|
||||
const createdResult = {
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const finalizedResult = {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: DOWNLOAD_URL } } },
|
||||
const createdSession = {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
|
||||
};
|
||||
|
||||
describe('sandbox artifact uploads', () => {
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let workerApi: {
|
||||
createArtifactUploadSessionAsync: jest.Mock;
|
||||
finalizeArtifactAsync: jest.Mock;
|
||||
};
|
||||
let logger: { info: jest.Mock; error: jest.Mock };
|
||||
let uploadArtifactAsync: (options: {
|
||||
filePath: string;
|
||||
@@ -64,13 +59,14 @@ describe('sandbox artifact uploads', () => {
|
||||
filePath = path.join(directory, 'crash.log');
|
||||
await fs.writeFile(filePath, 'crash');
|
||||
jest.mocked(fetch).mockReset();
|
||||
mutation = jest.fn();
|
||||
workerApi = {
|
||||
createArtifactUploadSessionAsync: jest.fn(),
|
||||
finalizeArtifactAsync: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), error: jest.fn() };
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
uploadArtifactAsync = async options =>
|
||||
await startSandboxArtifactUploadAsync({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
...options,
|
||||
});
|
||||
@@ -81,7 +77,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('returns the artifact id before the PUT completes', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
@@ -96,34 +92,29 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
expect(upload.id).toBe(ARTIFACT_ID);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
expect.anything()
|
||||
);
|
||||
expect(workerApi.finalizeArtifactAsync).not.toHaveBeenCalled();
|
||||
respond(new Response('', { status: 200 }));
|
||||
await expect(upload.completed).resolves.toEqual({
|
||||
status: 'uploaded',
|
||||
downloadUrl: DOWNLOAD_URL,
|
||||
});
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(
|
||||
ARTIFACT_ID,
|
||||
expect.objectContaining({ retries: 2 })
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends exactly the stat size on every attempt when the file grows', async () => {
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await fs.appendFile(filePath, ' and more');
|
||||
return createdResult;
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({ toPromise: async () => finalizedResult });
|
||||
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
|
||||
await fs.appendFile(filePath, ' and more');
|
||||
return createdSession;
|
||||
});
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(DOWNLOAD_URL);
|
||||
const bodies: string[] = [];
|
||||
const respondAfterReadingBody =
|
||||
(status: number) =>
|
||||
@@ -153,7 +144,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
it('uploads an empty file with an empty body', async () => {
|
||||
await fs.writeFile(filePath, '');
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
let body: string | undefined;
|
||||
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -171,9 +162,8 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Empty log', filename: 'crash.log', size: 0 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Empty log', filename: 'crash.log', size: 0 },
|
||||
expect.anything()
|
||||
);
|
||||
expect(body).toBe('');
|
||||
@@ -181,7 +171,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('finalizes when a retry fails the upload precondition', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
@@ -199,33 +189,20 @@ describe('sandbox artifact uploads', () => {
|
||||
await upload.completed;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(ARTIFACT_ID, expect.anything());
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenLastCalledWith(
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}, 5 bytes).`
|
||||
);
|
||||
});
|
||||
|
||||
it('retries finalize after a server error and logs a failure that persists', async () => {
|
||||
mockMutationResults(
|
||||
createdResult,
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
}
|
||||
);
|
||||
it('logs a finalize failure and reports the upload as failed', async () => {
|
||||
mockWorkerApi({
|
||||
finalize: new WorkerApiError(
|
||||
'POST /v2/sandbox-worker/artifacts/x/finalize failed with status 502: Bad Gateway',
|
||||
502
|
||||
),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
@@ -235,21 +212,23 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
await expect(upload.completed).resolves.toEqual({ status: 'failed' });
|
||||
expect(mutation).toHaveBeenCalledTimes(3);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}), but could not finalize it.`
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['VALIDATION_ERROR', 'UNAUTHORIZED'])(
|
||||
'rejects a %s upload session error as a bad request with the GraphQL message',
|
||||
async errorCode => {
|
||||
it.each([
|
||||
{ status: 400, code: 'VALIDATION_ERROR' },
|
||||
{ status: 403, code: 'UNAUTHORIZED' },
|
||||
])(
|
||||
'rejects a $code upload session error as a bad request with the API message',
|
||||
async ({ status, code }) => {
|
||||
const message =
|
||||
'Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
|
||||
mockMutationResults({
|
||||
error: new CombinedError({ graphQLErrors: [{ message, extensions: { errorCode } }] }),
|
||||
});
|
||||
'POST /v2/sandbox-worker/artifact-upload-sessions failed with status 400: Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
|
||||
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(
|
||||
new WorkerApiError(message, status, undefined, code)
|
||||
);
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
@@ -265,23 +244,16 @@ describe('sandbox artifact uploads', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unexpected upload session error as a system error', async () => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Request failed.', extensions: { errorCode: 'UNEXPECTED_ERROR' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
it.each([
|
||||
{ status: 500, code: 'UNEXPECTED_ERROR' },
|
||||
{ status: 401, code: 'AUTHENTICATION_ERROR' },
|
||||
])('rethrows a $code upload session error unchanged', async ({ status, code }) => {
|
||||
const error = new WorkerApiError('Request failed.', status, undefined, code);
|
||||
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(error);
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(upload).rejects.toMatchObject({ message: 'Request failed.' });
|
||||
await expect(
|
||||
uploadArtifactAsync({ filePath, name: 'Crash log', signal: new AbortController().signal })
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects a missing file as a bad request without creating an upload session', async () => {
|
||||
@@ -297,11 +269,11 @@ describe('sandbox artifact uploads', () => {
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `File does not exist: ${missingPath}`,
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the signed URL out of logs', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
mockWorkerApi({});
|
||||
jest.mocked(fetch).mockRejectedValue(
|
||||
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
|
||||
code: 'ECONNRESET',
|
||||
@@ -323,7 +295,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('waits for an aborted upload before the manager reports that it stopped', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
mockWorkerApi({});
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
const manager = createManager(controller.signal);
|
||||
@@ -346,36 +318,24 @@ describe('sandbox artifact uploads', () => {
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
case: 'a finalized upload as uploaded with its download URL',
|
||||
finalizeResult: finalizedResult,
|
||||
finalize: DOWNLOAD_URL,
|
||||
outcome: { status: 'uploaded', downloadUrl: DOWNLOAD_URL },
|
||||
},
|
||||
{
|
||||
case: 'a failed finalization as failed',
|
||||
finalizeResult: {
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
},
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
{
|
||||
case: 'a finalized artifact without a download URL as failed',
|
||||
finalizeResult: {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
},
|
||||
finalize: new WorkerApiError('Sandbox artifact "Crash log" has no uploaded file.', 400),
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
])(
|
||||
'waits for an upload that ends within the yield time and reports $case',
|
||||
async ({ finalizeResult, outcome }) => {
|
||||
mockMutationResults(createdResult, finalizeResult);
|
||||
async ({ finalize, outcome }) => {
|
||||
mockWorkerApi({ finalize });
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
const manager = createManager(new AbortController().signal);
|
||||
|
||||
@@ -390,12 +350,10 @@ describe('sandbox artifact uploads', () => {
|
||||
const finalizeCalledPromise = new Promise<void>(resolve => {
|
||||
finalizeCalled = resolve;
|
||||
});
|
||||
mockMutationResults(createdResult);
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
finalizeCalled();
|
||||
return finalizedResult;
|
||||
},
|
||||
mockWorkerApi({});
|
||||
workerApi.finalizeArtifactAsync.mockImplementationOnce(async () => {
|
||||
finalizeCalled();
|
||||
return DOWNLOAD_URL;
|
||||
});
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
@@ -413,11 +371,9 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('counts upload session creation toward the yield time', async () => {
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await delay(400);
|
||||
return createdResult;
|
||||
},
|
||||
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
|
||||
await delay(400);
|
||||
return createdSession;
|
||||
});
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
@@ -434,8 +390,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
|
||||
return new SandboxArtifactUploadManager({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
signal,
|
||||
});
|
||||
@@ -451,9 +406,12 @@ describe('sandbox artifact uploads', () => {
|
||||
}) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
function mockWorkerApi({ finalize }: { finalize?: string | Error }): void {
|
||||
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce(createdSession);
|
||||
if (finalize instanceof Error) {
|
||||
workerApi.finalizeArtifactAsync.mockRejectedValueOnce(finalize);
|
||||
} else if (finalize !== undefined) {
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(finalize);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
jest.unmock('fs');
|
||||
@@ -16,7 +17,7 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
const graphqlClient = {} as Client;
|
||||
const workerApi = {} as SandboxWorkerApi | LegacySandboxApi;
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
@@ -26,8 +27,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -53,8 +53,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
@@ -89,8 +88,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -121,8 +119,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
signal: controller.signal,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
@@ -151,8 +148,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
@@ -186,8 +182,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
@@ -219,8 +214,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -264,8 +258,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -350,8 +343,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -396,8 +388,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
|
||||
@@ -5,7 +5,6 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
import { type SandboxDaemonCommandResult, SandboxDaemonErrorCode } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import fetch from 'node-fetch';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
|
||||
const FIXTURES_DIRECTORY = path.join(__dirname, 'fixtures');
|
||||
@@ -31,22 +32,25 @@ describe('sandbox daemon commands', () => {
|
||||
let commandImplementations: SandboxDaemonCommandImplementations;
|
||||
let stopAsync: () => Promise<void>;
|
||||
let workingDirectory: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let workerApi: {
|
||||
createArtifactUploadSessionAsync: jest.Mock;
|
||||
finalizeArtifactAsync: jest.Mock;
|
||||
};
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
|
||||
mutation = jest.fn();
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
workerApi = {
|
||||
createArtifactUploadSessionAsync: jest.fn(),
|
||||
finalizeArtifactAsync: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
const controller = new AbortController();
|
||||
const commands = createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
logger,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
@@ -75,8 +79,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
logger,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
@@ -430,37 +433,17 @@ describe('sandbox daemon commands', () => {
|
||||
`${path.join(workingDirectory, 'dist')} is not a regular file. To upload a directory, create an archive first`
|
||||
),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads an artifact from a path relative to the working directory and waits for it by default', async () => {
|
||||
await fs.mkdir(path.join(workingDirectory, 'logs'));
|
||||
await fs.writeFile(path.join(workingDirectory, 'logs', 'crash.log'), 'crash');
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
finalizeArtifact: {
|
||||
id: ARTIFACT_ID,
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
});
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce('https://downloads.expo.test/artifact');
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const result = await commandImplementations.uploadArtifact({
|
||||
@@ -473,9 +456,8 @@ describe('sandbox daemon commands', () => {
|
||||
status: 'uploaded',
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ArchiveSourceType, EnvironmentSecretType, type Job } from '@expo/eas-build-job';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { SandboxWorkerApi, getSandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/';
|
||||
const TOKEN = 'sandbox-worker-token-value';
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
function jsonResponse(body: unknown): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe(getSandboxWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('returns null when the job has no sandbox worker token', () => {
|
||||
expect(
|
||||
getSandboxWorkerApi({
|
||||
job: { secrets: { environmentSecrets: [] } } as unknown as Job,
|
||||
env: { EAS_SANDBOX_WORKER_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the token from the job secrets', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'sandbox-id' } }));
|
||||
const api = getSandboxWorkerApi({
|
||||
job: {
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{ name: 'EAS_SANDBOX_WORKER_TOKEN', type: EnvironmentSecretType.STRING, value: TOKEN },
|
||||
],
|
||||
},
|
||||
} as unknown as Job,
|
||||
env: {},
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
});
|
||||
|
||||
await api!.markReadyAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/sandbox-worker/ready',
|
||||
'POST',
|
||||
expect.objectContaining({ headers: { 'expo-sandbox-worker-token': TOKEN } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SandboxWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'getProjectArchiveSourceAsync',
|
||||
call: (api: SandboxWorkerApi) => api.getProjectArchiveSourceAsync(),
|
||||
route: 'project-archive',
|
||||
json: undefined,
|
||||
data: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
|
||||
expected: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
|
||||
},
|
||||
{
|
||||
name: 'markReadyAsync',
|
||||
call: (api: SandboxWorkerApi) => api.markReadyAsync(),
|
||||
route: 'ready',
|
||||
json: undefined,
|
||||
data: { id: 'sandbox-id' },
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: 'createArtifactUploadSessionAsync',
|
||||
call: (api: SandboxWorkerApi) =>
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 }),
|
||||
route: 'artifact-upload-sessions',
|
||||
json: { name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
data: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
expected: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'finalizeArtifactAsync',
|
||||
call: (api: SandboxWorkerApi) => api.finalizeArtifactAsync(ARTIFACT_ID),
|
||||
route: `artifacts/${ARTIFACT_ID}/finalize`,
|
||||
json: undefined,
|
||||
data: { id: ARTIFACT_ID, downloadUrl: 'https://downloads.expo.test/a' },
|
||||
expected: 'https://downloads.expo.test/a',
|
||||
},
|
||||
])(
|
||||
'$name calls POST /v2/sandbox-worker/$route with the sandbox worker token header',
|
||||
async ({ call, route, json, data, expected }) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
|
||||
|
||||
await expect(call(new SandboxWorkerApi(BASE_URL, TOKEN))).resolves.toEqual(expected);
|
||||
|
||||
const [url, method, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`https://api.expo.test/v2/sandbox-worker/${route}`);
|
||||
expect(method).toBe('POST');
|
||||
expect(options.headers).toEqual({ 'expo-sandbox-worker-token': TOKEN });
|
||||
expect(options.json).toEqual(json);
|
||||
}
|
||||
);
|
||||
|
||||
it('escapes the artifact ID in the finalize route', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'a', downloadUrl: 'https://d.test/a' } }));
|
||||
|
||||
await new SandboxWorkerApi(BASE_URL, TOKEN).finalizeArtifactAsync('../ready');
|
||||
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][0]).toBe(
|
||||
'https://api.expo.test/v2/sandbox-worker/artifacts/..%2Fready/finalize'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
WorkerApi,
|
||||
WorkerApiError,
|
||||
type WorkerApiRequestOptions,
|
||||
getExpoApiV2BaseUrl,
|
||||
} from '../workerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/test-worker/';
|
||||
const AUTH = { authHeader: 'expo-test-worker-token', token: 'test-worker-token-value' };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const ThingSchema = z.object({ id: z.string() });
|
||||
|
||||
async function getThingAsync(options?: WorkerApiRequestOptions): Promise<{ id: string }> {
|
||||
return await new WorkerApi(BASE_URL, AUTH).requestAsync('GET', 'thing', ThingSchema, options);
|
||||
}
|
||||
|
||||
async function createThingAsync(
|
||||
json: Record<string, unknown>,
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<{ id: string }> {
|
||||
return await new WorkerApi(BASE_URL, AUTH).requestAsync('POST', 'things', ThingSchema, {
|
||||
...options,
|
||||
json,
|
||||
});
|
||||
}
|
||||
|
||||
describe(getExpoApiV2BaseUrl, () => {
|
||||
it('uses the API v2 base URL of the context', () => {
|
||||
expect(
|
||||
getExpoApiV2BaseUrl({
|
||||
env: { __API_SERVER_URL: 'https://other.expo.test' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
})
|
||||
).toBe('https://api.expo.test/v2/');
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', () => {
|
||||
expect(getExpoApiV2BaseUrl({ env: { __API_SERVER_URL: 'https://api.expo.test' } })).toBe(
|
||||
'https://api.expo.test/v2/'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when no API URL is known', () => {
|
||||
expect(() => getExpoApiV2BaseUrl({ env: {} })).toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('resolves routes under the worker router and sends only the worker auth header', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
|
||||
await expect(createThingAsync({ name: 'a' })).resolves.toEqual({ id: 'thing-id' });
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
const [url, method, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe('https://api.expo.test/v2/test-worker/things');
|
||||
expect(method).toBe('POST');
|
||||
expect(options.headers).toEqual({ 'expo-test-worker-token': 'test-worker-token-value' });
|
||||
expect(options.json).toEqual({ name: 'a' });
|
||||
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
|
||||
});
|
||||
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await getThingAsync({ signal: controller.signal, timeoutMs: 5000, retries: 1 });
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'GET',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message, code and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This worker no longer accepts requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createThingAsync({}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.code).toBe('UNAUTHORIZED_ERROR');
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/test-worker/things failed with status 403: This worker no longer accepts requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(AUTH.token);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await getThingAsync().catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe('GET /v2/test-worker/thing failed with status 502: Bad Gateway');
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await getThingAsync().catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.isTransient).toBe(true);
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }));
|
||||
|
||||
await expect(getThingAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/test-worker/thing returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(getThingAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/test-worker/thing returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worker API retries', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])('recovers from HTTP %s', async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const result = createThingAsync({}, { retries: 3, timeoutMs: 30_000 });
|
||||
const assertion = expect(result).resolves.toEqual({ id: 'thing-id' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(jest.mocked(turtleFetch).mock.calls[0]);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers from a network failure', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const assertion = expect(getThingAsync({ retries: 3 })).resolves.toEqual({
|
||||
id: 'thing-id',
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('exhausts three retries with exponential backoff', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(createThingAsync({}, { retries: 3 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
|
||||
await expect(createThingAsync({}, { retries: 3 })).rejects.toMatchObject({
|
||||
status,
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry without an explicit retry count', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(createThingAsync({})).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit retry count', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(getThingAsync({ retries: 1 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not send another request after cancellation during backoff', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
getThingAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
controller.abort();
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send an already cancelled request', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const assertion = expect(
|
||||
getThingAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['system', 'body-timeout'])(
|
||||
'retries a response body transport failure (%s)',
|
||||
async type => {
|
||||
const response = jsonResponse({});
|
||||
jest
|
||||
.spyOn(response, 'json')
|
||||
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const assertion = expect(createThingAsync({}, { retries: 3 })).resolves.toEqual({
|
||||
id: 'thing-id',
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { type EnvironmentSecret, SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { getStringEnvironmentSecretValue } from '../../utils/environmentSecrets';
|
||||
import { WorkerApi, type WorkerApiRequestOptions, getExpoApiV2BaseUrl } from './workerApi';
|
||||
|
||||
/**
|
||||
* The environment secret that holds the device run session token. www gives it only to the job of
|
||||
@@ -20,8 +17,6 @@ export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
|
||||
*/
|
||||
export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
|
||||
|
||||
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
|
||||
|
||||
const DeviceRunSessionStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
// The status entity values that www returns.
|
||||
@@ -55,10 +50,6 @@ const DeviceRunSessionApplicationSchema = z.object({
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const ApiV2ErrorResponseSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
|
||||
});
|
||||
|
||||
export type DeviceRunSessionStatus = z.infer<typeof DeviceRunSessionStatusSchema>;
|
||||
export type DeviceRunSessionUploadSession = z.infer<typeof UploadSessionSchema>['uploadSession'];
|
||||
export type DeviceRunSessionTurnIceServers = z.infer<typeof TurnIceServersSchema>['iceServers'];
|
||||
@@ -72,70 +63,48 @@ export type CreateDeviceRunSessionArtifactUploadSessionInput = {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
/** Stops the request when aborted. */
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
|
||||
/**
|
||||
* A request to the device run session worker API failed. The message names the route and the
|
||||
* reason. It never includes the device run session token.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the www routes under `/v2/device-run-session-worker/`. These routes act only on the
|
||||
* session that the token names, so no request carries a session ID.
|
||||
* session that the token names.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApi {
|
||||
constructor(
|
||||
private readonly expoApiV2BaseUrl: string,
|
||||
private readonly deviceRunSessionToken: string
|
||||
) {}
|
||||
private readonly client: WorkerApi;
|
||||
|
||||
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
constructor(expoApiV2BaseUrl: string, token: string) {
|
||||
this.client = new WorkerApi(
|
||||
new URL('device-run-session-worker/', expoApiV2BaseUrl).toString(),
|
||||
{
|
||||
authHeader: DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
token,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getSessionAsync(
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.client.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async startAsync(
|
||||
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
return await this.client.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
...options,
|
||||
json: { remoteConfig },
|
||||
});
|
||||
}
|
||||
|
||||
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
public async stopAsync(options: WorkerApiRequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.client.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: CreateDeviceRunSessionArtifactUploadSessionInput,
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
UploadSessionSchema,
|
||||
@@ -145,9 +114,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async createEventLogUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'event-log-upload-session',
|
||||
UploadSessionSchema,
|
||||
@@ -157,9 +126,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async createPreviewUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'preview-upload-session',
|
||||
UploadSessionSchema,
|
||||
@@ -169,9 +138,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async fetchTurnIceServersAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionTurnIceServers> {
|
||||
const { iceServers } = await this.requestAsync(
|
||||
const { iceServers } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'turn-ice-servers',
|
||||
TurnIceServersSchema,
|
||||
@@ -181,160 +150,26 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async getApplicationAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionApplication> {
|
||||
return await this.requestAsync(
|
||||
return await this.client.requestAsync(
|
||||
'GET',
|
||||
'application',
|
||||
DeviceRunSessionApplicationSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
|
||||
): Promise<T> {
|
||||
const request = () => {
|
||||
options.signal?.throwIfAborted();
|
||||
return this.requestOnceAsync(method, route, dataSchema, options);
|
||||
};
|
||||
if (retries === 0) {
|
||||
return await request();
|
||||
}
|
||||
return await promiseRetryWithCondition(
|
||||
request,
|
||||
error =>
|
||||
!options.signal?.aborted &&
|
||||
error instanceof DeviceRunSessionWorkerApiError &&
|
||||
(error.status === undefined || error.status === 429 || error.status >= 500),
|
||||
{ retries, factor: 2, minTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) =>
|
||||
options.logger?.warn(
|
||||
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async requestOnceAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
logger,
|
||||
json,
|
||||
}: Omit<RequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
|
||||
const url = new URL(
|
||||
`${DEVICE_RUN_SESSION_WORKER_PATH}${route}`,
|
||||
this.expoApiV2BaseUrl
|
||||
).toString();
|
||||
|
||||
const responseResult = await asyncResult(
|
||||
turtleFetch(url, method, {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: this.deviceRunSessionToken },
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
const reason = responseResult.reason;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
const bodyResult = await asyncResult(response.json());
|
||||
if (!response.ok) {
|
||||
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
|
||||
const apiErrorMessage = apiError?.success
|
||||
? apiError.data.errors.map(error => error.message).join(' ')
|
||||
: response.statusText;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
if (
|
||||
bodyResult.reason instanceof FetchError &&
|
||||
['system', 'body-timeout'].includes(bodyResult.reason.type)
|
||||
) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed while reading the response body.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
|
||||
if (!parsed.success) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token when www gave one to this job, or null. Without a token,
|
||||
* callers keep using the robot access token and the account-wide API.
|
||||
*/
|
||||
export function getDeviceRunSessionToken(
|
||||
env: Record<string, string | undefined> | undefined
|
||||
): string | null {
|
||||
const token = env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token from the job's environment secrets, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins), so the worker and the
|
||||
* steps use the same token. A file secret is not a token.
|
||||
*/
|
||||
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
const secret = [...(environmentSecrets ?? [])]
|
||||
.reverse()
|
||||
.find(({ name }) => name === DEVICE_RUN_SESSION_TOKEN_ENV);
|
||||
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
|
||||
return null;
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a client for the device run session worker API when this job has a device run session
|
||||
* token, or null when it does not.
|
||||
* token, or null when it does not. Without a token, callers keep using the robot access token and
|
||||
* the account-wide API.
|
||||
*/
|
||||
export function getDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi | null {
|
||||
const token = getDeviceRunSessionToken(ctx.env);
|
||||
const token = ctx.env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
@@ -359,16 +194,13 @@ export function requireDeviceRunSessionWorkerApi(
|
||||
return workerApi;
|
||||
}
|
||||
|
||||
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
|
||||
if (ctx.expoApiV2BaseUrl) {
|
||||
return ctx.expoApiV2BaseUrl;
|
||||
}
|
||||
if (ctx.env.__API_SERVER_URL) {
|
||||
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
|
||||
}
|
||||
throw new SystemError(
|
||||
`${DEVICE_RUN_SESSION_TOKEN_ENV} is set, but the API server URL is not. ` +
|
||||
'The worker cannot call the device run session API without it. ' +
|
||||
'Check that the worker sets __API_SERVER_URL for device run session jobs.'
|
||||
);
|
||||
/**
|
||||
* Returns the device run session token from the job's environment secrets, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins), so the worker and the
|
||||
* steps use the same token. A file secret is not a token.
|
||||
*/
|
||||
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
return getStringEnvironmentSecretValue(environmentSecrets, DEVICE_RUN_SESSION_TOKEN_ENV);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { type Env, SystemError } from '@expo/eas-build-job';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { type SandboxArtifactUploadSession } from './sandboxWorkerApi';
|
||||
import { WorkerApiError, type WorkerApiRequestOptions } from './workerApi';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
$sandboxId: ID!
|
||||
$input: CreateSandboxArtifactUploadSessionInput!
|
||||
) {
|
||||
sandbox {
|
||||
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
|
||||
artifact {
|
||||
id
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
|
||||
mutation FinalizeSandboxArtifact($artifactId: ID!) {
|
||||
sandbox {
|
||||
finalizeArtifact(artifactId: $artifactId) {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_ATTEMPTS = 3;
|
||||
const FINALIZE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class LegacySandboxApi {
|
||||
constructor(
|
||||
private readonly graphqlClient: Client,
|
||||
private readonly sandboxId: string
|
||||
) {}
|
||||
|
||||
public async markReadyAsync({ signal }: WorkerApiRequestOptions = {}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId: this.sandboxId },
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: { name: string; filename: string; size: number },
|
||||
{ signal, timeoutMs }: WorkerApiRequestOptions = {}
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
const requestSignal = AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutMs !== undefined ? [AbortSignal.timeout(timeoutMs)] : []),
|
||||
]);
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{ sandboxId: this.sandboxId, input },
|
||||
graphqlAbortContext(requestSignal)
|
||||
)
|
||||
.toPromise();
|
||||
requestSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
const { graphQLErrors } = result.error;
|
||||
const codes = Array.from(new Set(graphQLErrors.map(error => error.extensions?.errorCode)));
|
||||
throw new WorkerApiError(
|
||||
graphQLErrors.length > 0
|
||||
? graphQLErrors.map(error => error.message).join('\n')
|
||||
: result.error.message,
|
||||
result.error.response?.status,
|
||||
{ cause: result.error },
|
||||
codes.length === 1 && typeof codes[0] === 'string' ? codes[0] : undefined
|
||||
);
|
||||
}
|
||||
const { artifact, uploadSession } = result.data!.sandbox.createArtifactUploadSession;
|
||||
return {
|
||||
artifact,
|
||||
uploadSession: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async finalizeArtifactAsync(
|
||||
artifactId: string,
|
||||
{ signal }: WorkerApiRequestOptions = {}
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const timeoutSignal = AbortSignal.timeout(FINALIZE_TIMEOUT_MS);
|
||||
const finalizeSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
try {
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
|
||||
{ artifactId },
|
||||
graphqlAbortContext(finalizeSignal)
|
||||
)
|
||||
.toPromise();
|
||||
finalizeSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
|
||||
if (downloadUrl === null) {
|
||||
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
|
||||
}
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
timeoutSignal.aborted ||
|
||||
(error instanceof CombinedError && isTransientGraphqlError(error));
|
||||
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * attempt, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLegacySandboxConnection({
|
||||
graphqlClient,
|
||||
env,
|
||||
sandboxId,
|
||||
}: {
|
||||
graphqlClient: Client;
|
||||
env: Env;
|
||||
sandboxId: string;
|
||||
}): { sandboxApi: LegacySandboxApi; credential: string; commandEnv: Env } {
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: credential, ...commandEnv } = env;
|
||||
if (!credential) {
|
||||
throw new SystemError(
|
||||
'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN. ' +
|
||||
'www gives one of them to every sandbox job. Start a new sandbox, and contact support if ' +
|
||||
'it happens again.'
|
||||
);
|
||||
}
|
||||
return { sandboxApi: new LegacySandboxApi(graphqlClient, sandboxId), credential, commandEnv };
|
||||
}
|
||||
|
||||
function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
const status: number | undefined = error.response?.status;
|
||||
return status === undefined ? error.networkError !== undefined : status >= 500;
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
type DeviceRunSessionStatus,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from './deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
|
||||
@@ -525,10 +525,7 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
if (err instanceof WorkerApiError && err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED') {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
@@ -580,10 +577,7 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
return 'started';
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
if (err instanceof WorkerApiError && err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
@@ -2,52 +2,21 @@ import {
|
||||
type SandboxDaemonCommandResult,
|
||||
SandboxDaemonError,
|
||||
SandboxDaemonErrorCode,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxArtifactUploadSession, type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
$sandboxId: ID!
|
||||
$input: CreateSandboxArtifactUploadSessionInput!
|
||||
) {
|
||||
sandbox {
|
||||
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
|
||||
artifact {
|
||||
id
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
|
||||
mutation FinalizeSandboxArtifact($artifactId: ID!) {
|
||||
sandbox {
|
||||
finalizeArtifact(artifactId: $artifactId) {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_ATTEMPTS = 3;
|
||||
const WORKER_API_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const FINALIZE_RETRIES = 2;
|
||||
const REJECTED_UPLOAD_SESSION_ERROR_CODES = new Set(['VALIDATION_ERROR', 'UNAUTHORIZED']);
|
||||
|
||||
type SandboxArtifactUploadOutcome =
|
||||
@@ -60,8 +29,7 @@ export class SandboxArtifactUploadManager {
|
||||
|
||||
public constructor(
|
||||
private readonly options: {
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -114,15 +82,13 @@ export class SandboxArtifactUploadManager {
|
||||
}
|
||||
|
||||
export async function startSandboxArtifactUploadAsync({
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
filePath,
|
||||
name,
|
||||
signal,
|
||||
}: {
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
logger: bunyan;
|
||||
filePath: string;
|
||||
name: string;
|
||||
@@ -157,9 +123,9 @@ export async function startSandboxArtifactUploadAsync({
|
||||
artifact: { id: artifactId },
|
||||
uploadSession,
|
||||
} = await createSandboxArtifactUploadSessionAsync(
|
||||
graphqlClient,
|
||||
{ sandboxId, name, filename: path.basename(filePath), size },
|
||||
AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
||||
workerApi,
|
||||
{ name, filename: path.basename(filePath), size },
|
||||
signal
|
||||
);
|
||||
const completed = (async (): Promise<SandboxArtifactUploadOutcome> => {
|
||||
logger.info(`Uploading sandbox artifact "${name}" (${artifactId}, ${size} bytes).`);
|
||||
@@ -171,7 +137,12 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
let downloadUrl: string;
|
||||
try {
|
||||
downloadUrl = await finalizeSandboxArtifactAsync(graphqlClient, artifactId, signal);
|
||||
downloadUrl = await workerApi.finalizeArtifactAsync(artifactId, {
|
||||
signal,
|
||||
timeoutMs: WORKER_API_REQUEST_TIMEOUT_MS,
|
||||
retries: FINALIZE_RETRIES,
|
||||
logger,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error },
|
||||
@@ -186,44 +157,28 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
|
||||
async function createSandboxArtifactUploadSessionAsync(
|
||||
graphqlClient: Client,
|
||||
{
|
||||
sandboxId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
}: { sandboxId: string; name: string; filename: string; size: number },
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi,
|
||||
input: { name: string; filename: string; size: number },
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{ sandboxId, input: { name, filename, size } },
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { graphQLErrors } = result.error;
|
||||
const message =
|
||||
graphQLErrors.length > 0
|
||||
? graphQLErrors.map(error => error.message).join('\n')
|
||||
: `Failed to create an upload session for sandbox artifact "${name}": ${result.error.message}`;
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
try {
|
||||
return await workerApi.createArtifactUploadSessionAsync(input, {
|
||||
signal,
|
||||
timeoutMs: WORKER_API_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
graphQLErrors.length > 0 &&
|
||||
graphQLErrors.every(error =>
|
||||
REJECTED_UPLOAD_SESSION_ERROR_CODES.has(error.extensions?.errorCode as string)
|
||||
)
|
||||
error instanceof WorkerApiError &&
|
||||
error.code !== undefined &&
|
||||
REJECTED_UPLOAD_SESSION_ERROR_CODES.has(error.code)
|
||||
) {
|
||||
throw new SandboxDaemonError(SandboxDaemonErrorCode.BAD_REQUEST, message, {
|
||||
cause: result.error,
|
||||
throw new SandboxDaemonError(SandboxDaemonErrorCode.BAD_REQUEST, error.message, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new SystemError(message, { cause: result.error });
|
||||
throw error;
|
||||
}
|
||||
return result.data!.sandbox.createArtifactUploadSession;
|
||||
}
|
||||
|
||||
async function putSandboxArtifactAsync({
|
||||
@@ -234,16 +189,13 @@ async function putSandboxArtifactAsync({
|
||||
}: {
|
||||
filePath: string;
|
||||
size: number;
|
||||
uploadSession: { url: string; headers: unknown };
|
||||
uploadSession: SandboxArtifactUploadSession['uploadSession'];
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
signedUrl: uploadSession,
|
||||
srcGeneratorAsync: async () =>
|
||||
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
|
||||
},
|
||||
@@ -255,45 +207,3 @@ async function putSandboxArtifactAsync({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeSandboxArtifactAsync(
|
||||
graphqlClient: Client,
|
||||
artifactId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
const finalizeSignal = AbortSignal.any([signal, timeoutSignal]);
|
||||
try {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
|
||||
{ artifactId },
|
||||
graphqlAbortContext(finalizeSignal)
|
||||
)
|
||||
.toPromise();
|
||||
finalizeSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
|
||||
if (downloadUrl === null) {
|
||||
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
|
||||
}
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
const retryable =
|
||||
timeoutSignal.aborted || (error instanceof CombinedError && isTransientGraphqlError(error));
|
||||
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * attempt, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
const status: number | undefined = error.response?.status;
|
||||
return status === undefined ? error.networkError !== undefined : status >= 500;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type SandboxDaemonMethod,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
@@ -14,6 +13,8 @@ import { performance } from 'node:perf_hooks';
|
||||
import { readBytesAsync, readTextAsync } from '../../utils/files';
|
||||
import { detectFileType } from '../../utils/files/filetypes';
|
||||
import { SandboxArtifactUploadManager } from './sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { ShellSessionManager } from './shellSessionManager';
|
||||
|
||||
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
|
||||
@@ -30,15 +31,13 @@ export function createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env,
|
||||
signal,
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
logger: bunyan;
|
||||
}): {
|
||||
commandImplementations: SandboxDaemonCommandImplementations;
|
||||
@@ -46,8 +45,7 @@ export function createSandboxCommandImplementations({
|
||||
} {
|
||||
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
|
||||
const artifactUploads = new SandboxArtifactUploadManager({
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from './sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
@@ -26,8 +27,7 @@ export interface SandboxDaemonOptions {
|
||||
signal?: AbortSignal;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
@@ -49,8 +49,7 @@ export async function startSandboxDaemonAsync(
|
||||
workingDirectory: options.workingDirectory,
|
||||
env: options.env,
|
||||
signal: abortController.signal,
|
||||
graphqlClient: options.graphqlClient,
|
||||
sandboxId: options.sandboxId,
|
||||
workerApi: options.workerApi,
|
||||
logger: options.logger,
|
||||
});
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type Env, type EnvironmentSecret, type Job } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type ProjectArchiveSource,
|
||||
ProjectArchiveSourceSchema,
|
||||
} from '../../common/projectArchiveSource';
|
||||
import { getStringEnvironmentSecretValue } from '../../utils/environmentSecrets';
|
||||
import { WorkerApi, type WorkerApiRequestOptions, getExpoApiV2BaseUrl } from './workerApi';
|
||||
|
||||
export const SANDBOX_WORKER_TOKEN_ENV = 'EAS_SANDBOX_WORKER_TOKEN';
|
||||
|
||||
export const SANDBOX_WORKER_TOKEN_HEADER = 'expo-sandbox-worker-token';
|
||||
|
||||
const MarkReadyResponseSchema = z.object({ id: z.string() });
|
||||
|
||||
const ArtifactUploadSessionSchema = z.object({
|
||||
artifact: z.object({ id: z.string() }),
|
||||
uploadSession: z.object({
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
const FinalizedArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
export type SandboxArtifactUploadSession = z.infer<typeof ArtifactUploadSessionSchema>;
|
||||
|
||||
export class SandboxWorkerApi {
|
||||
private readonly client: WorkerApi;
|
||||
|
||||
constructor(
|
||||
expoApiV2BaseUrl: string,
|
||||
readonly token: string
|
||||
) {
|
||||
this.client = new WorkerApi(new URL('sandbox-worker/', expoApiV2BaseUrl).toString(), {
|
||||
authHeader: SANDBOX_WORKER_TOKEN_HEADER,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
public async getProjectArchiveSourceAsync(
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<ProjectArchiveSource> {
|
||||
return await this.client.requestAsync(
|
||||
'POST',
|
||||
'project-archive',
|
||||
ProjectArchiveSourceSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
public async markReadyAsync(options: WorkerApiRequestOptions = {}): Promise<void> {
|
||||
await this.client.requestAsync('POST', 'ready', MarkReadyResponseSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: { name: string; filename: string; size: number },
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
return await this.client.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
ArtifactUploadSessionSchema,
|
||||
{ ...options, json: input }
|
||||
);
|
||||
}
|
||||
|
||||
public async finalizeArtifactAsync(
|
||||
artifactId: string,
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<string> {
|
||||
const { downloadUrl } = await this.client.requestAsync(
|
||||
'POST',
|
||||
`artifacts/${encodeURIComponent(artifactId)}/finalize`,
|
||||
FinalizedArtifactSchema,
|
||||
options
|
||||
);
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSandboxWorkerTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
return getStringEnvironmentSecretValue(environmentSecrets, SANDBOX_WORKER_TOKEN_ENV);
|
||||
}
|
||||
|
||||
export function getSandboxWorkerApi(ctx: {
|
||||
job: Job;
|
||||
env: Env;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}): SandboxWorkerApi | null {
|
||||
const token = getSandboxWorkerTokenFromEnvironmentSecrets(ctx.job.secrets?.environmentSecrets);
|
||||
return token ? new SandboxWorkerApi(getExpoApiV2BaseUrl(ctx), token) : null;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { type Env, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
/**
|
||||
* The header and value that authenticate a worker to its www worker router. The token is not an
|
||||
* `Authorization` credential, and www accepts it only on that router.
|
||||
*/
|
||||
export type WorkerApiAuth = {
|
||||
authHeader: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type WorkerApiRequestOptions = {
|
||||
/** Stops the request when aborted. */
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
|
||||
const ApiV2ErrorResponseSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* A request to a worker API failed. The message names the route and the reason. It never includes
|
||||
* the worker token.
|
||||
*/
|
||||
export class WorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'WorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for one www worker router. The token names the resource the worker serves, so no request
|
||||
* carries its ID.
|
||||
*/
|
||||
export class WorkerApi {
|
||||
constructor(
|
||||
/** The URL of the worker router. It ends with a slash, so routes resolve under it. */
|
||||
private readonly baseUrl: string,
|
||||
private readonly auth: WorkerApiAuth
|
||||
) {}
|
||||
|
||||
public async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{ retries = 0, ...options }: WorkerApiRequestOptions & { json?: Record<string, unknown> } = {}
|
||||
): Promise<T> {
|
||||
const url = new URL(route, this.baseUrl);
|
||||
const routeDescription = `${method} ${url.pathname}`;
|
||||
const request = () => {
|
||||
options.signal?.throwIfAborted();
|
||||
return this.requestOnceAsync(method, url.toString(), routeDescription, dataSchema, options);
|
||||
};
|
||||
if (retries === 0) {
|
||||
return await request();
|
||||
}
|
||||
return await promiseRetryWithCondition(
|
||||
request,
|
||||
error =>
|
||||
!options.signal?.aborted &&
|
||||
error instanceof WorkerApiError &&
|
||||
(error.status === undefined || error.status === 429 || error.status >= 500),
|
||||
{ retries, factor: 2, minTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) =>
|
||||
options.logger?.warn(
|
||||
`Retrying ${routeDescription} (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async requestOnceAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
routeDescription: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
logger,
|
||||
json,
|
||||
}: Omit<WorkerApiRequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const responseResult = await asyncResult(
|
||||
turtleFetch(url, method, {
|
||||
headers: { [this.auth.authHeader]: this.auth.token },
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
const reason = responseResult.reason;
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
const bodyResult = await asyncResult(response.json());
|
||||
if (!response.ok) {
|
||||
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
|
||||
const apiErrorMessage = apiError?.success
|
||||
? apiError.data.errors.map(error => error.message).join(' ')
|
||||
: response.statusText;
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
if (
|
||||
bodyResult.reason instanceof FetchError &&
|
||||
['system', 'body-timeout'].includes(bodyResult.reason.type)
|
||||
) {
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed while reading the response body.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
|
||||
if (!parsed.success) {
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the API v2 base URL of the worker. It throws when the worker knows no API server URL,
|
||||
* because a worker API client cannot work without it.
|
||||
*/
|
||||
export function getExpoApiV2BaseUrl(ctx: { env: Env; expoApiV2BaseUrl?: string }): string {
|
||||
if (ctx.expoApiV2BaseUrl) {
|
||||
return ctx.expoApiV2BaseUrl;
|
||||
}
|
||||
if (ctx.env.__API_SERVER_URL) {
|
||||
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
|
||||
}
|
||||
throw new SystemError(
|
||||
'The API server URL is not set, so the worker cannot call its worker API. ' +
|
||||
'Check that the worker sets __API_SERVER_URL.'
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
import { EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTemporaryEnvironmentSecretFile } from '../environmentSecrets';
|
||||
import {
|
||||
createTemporaryEnvironmentSecretFile,
|
||||
getStringEnvironmentSecretValue,
|
||||
} from '../environmentSecrets';
|
||||
|
||||
describe(getStringEnvironmentSecretValue, () => {
|
||||
it('returns the last string secret with the name, like the build env', () => {
|
||||
expect(
|
||||
getStringEnvironmentSecretValue(
|
||||
[
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'first' },
|
||||
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'other' },
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'last' },
|
||||
],
|
||||
'TOKEN'
|
||||
)
|
||||
).toBe('last');
|
||||
});
|
||||
|
||||
it('returns null when the last secret with the name is a file secret', () => {
|
||||
expect(
|
||||
getStringEnvironmentSecretValue(
|
||||
[
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'a' },
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.FILE, value: 'Yg==' },
|
||||
],
|
||||
'TOKEN'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(createTemporaryEnvironmentSecretFile, () => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Returns the value of the string environment secret with this name, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins). A file secret has no
|
||||
* string value.
|
||||
*/
|
||||
export function getStringEnvironmentSecretValue(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined,
|
||||
name: string
|
||||
): string | null {
|
||||
const secret = Array.from(environmentSecrets ?? [])
|
||||
.reverse()
|
||||
.find(secret => secret.name === name);
|
||||
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
|
||||
return null;
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
export function createTemporaryEnvironmentSecretFile({
|
||||
secretsDir,
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.4.1"
|
||||
"@expo/steps": "27.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.4.1",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
|
||||
- Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
|
||||
- Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
|
||||
- Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
+369
-198
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.4.1",
|
||||
"@expo/eas-json": "27.4.1",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/eas-json": "27.5.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/logger": "27.5.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.4.1",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -119,6 +119,7 @@
|
||||
"promise-limit": "2.7.0",
|
||||
"promise-retry": "2.0.1",
|
||||
"prompts": "2.4.2",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"sandbox-cli-detector": "0.2.0",
|
||||
@@ -159,6 +160,7 @@
|
||||
"@types/pngjs": "6.0.4",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
"@types/prompts": "2.4.2",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
|
||||
@@ -6,18 +6,20 @@ const { createRequire } = require('node:module');
|
||||
const root = fs.realpathSync(process.argv[2]);
|
||||
const cli = path.join(root, 'packages/eas-cli');
|
||||
const cliRequire = createRequire(path.join(cli, 'package.json'));
|
||||
const { oclif } = cliRequire('./package.json');
|
||||
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
|
||||
const { Config } = cliRequire('@oclif/core');
|
||||
const { Config, run } = cliRequire('@oclif/core');
|
||||
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
|
||||
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
|
||||
const EasHelp = require(path.join(cli, 'src/help')).default;
|
||||
const cases = JSON.parse(process.argv[3]);
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
|
||||
const events = [];
|
||||
let commandArgs;
|
||||
globalThis.__helpLifecycleEvents = events;
|
||||
|
||||
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
|
||||
EasCommand.prototype.run = async function () {
|
||||
commandArgs = this.argv;
|
||||
events.push({ event: 'run', id: this.id });
|
||||
};
|
||||
EasCommand.prototype.finally = async function () {};
|
||||
@@ -31,14 +33,26 @@ EasCommand.prototype.finally = async function () {};
|
||||
version: '1.0.0',
|
||||
oclif: {
|
||||
bin: 'eas',
|
||||
commands: { strategy: 'explicit', target: './commands.cjs' },
|
||||
additionalHelpFlags: ['-h'],
|
||||
helpClass: oclif.helpClass,
|
||||
commands: './commands',
|
||||
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
|
||||
},
|
||||
})
|
||||
);
|
||||
for (const command of ['simulator/control', 'simulator/index', 'account/login']) {
|
||||
const file = path.join(directory, 'commands', `${command}.js`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`exports.default = require(${JSON.stringify(path.join(cli, 'src/commands', command))}).default;`
|
||||
);
|
||||
}
|
||||
const helpFile = path.join(directory, `${oclif.helpClass}.js`);
|
||||
fs.mkdirSync(path.dirname(helpFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'commands.cjs'),
|
||||
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
|
||||
helpFile,
|
||||
`exports.default = require(${JSON.stringify(path.join(cli, 'src/help'))}).default;`
|
||||
);
|
||||
for (const event of ['prerun', 'postrun']) {
|
||||
fs.writeFileSync(
|
||||
@@ -48,13 +62,24 @@ EasCommand.prototype.finally = async function () {};
|
||||
}
|
||||
process.env.XDG_DATA_HOME = directory;
|
||||
process.env.XDG_CACHE_HOME = directory;
|
||||
const config = await Config.load({ root: directory });
|
||||
let config = await Config.load({ root: directory });
|
||||
if (process.argv[4] === 'manifest') {
|
||||
if (!config.rootPlugin.manifest.commands['simulator:control'].relativePath) {
|
||||
throw new Error('The manifest must exercise cached command module paths');
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'oclif.manifest.json'),
|
||||
JSON.stringify(config.rootPlugin.manifest)
|
||||
);
|
||||
config = await Config.load({ root: directory });
|
||||
}
|
||||
const results = [];
|
||||
for (const args of cases) {
|
||||
// Published manifests discover metadata without loading the command class.
|
||||
// Clear eager discovery's assignment to exercise that cold-start boundary.
|
||||
delete Control.id;
|
||||
const start = events.length;
|
||||
commandArgs = undefined;
|
||||
const write = process.stdout.write;
|
||||
let stdout = '',
|
||||
error;
|
||||
@@ -65,13 +90,13 @@ EasCommand.prototype.finally = async function () {};
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await new EasHelp(config).showHelp(args);
|
||||
await run(args, config);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
results.push({ stdout, events: events.slice(start), error });
|
||||
results.push({ stdout, events: events.slice(start), error, commandArgs });
|
||||
}
|
||||
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
|
||||
} finally {
|
||||
|
||||
@@ -5,47 +5,99 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
|
||||
const modes = ['discovery', 'manifest'];
|
||||
const actionHelp = [
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
['simulators:control', 'rotate', '--help'],
|
||||
['emulator:control', 'rotate', '-h'],
|
||||
['emulators:control', 'rotate', '--help'],
|
||||
];
|
||||
const incompleteFlags = [
|
||||
const configHelp = [
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['simulator:start', '--help'],
|
||||
['simulator', '--help'],
|
||||
['sim:start', '-h'],
|
||||
['sim', '-h'],
|
||||
['simulators:start', '--help'],
|
||||
['emulator:start', '-h'],
|
||||
['emulators:start', '--help'],
|
||||
['simulators', '--help'],
|
||||
['emulator', '-h'],
|
||||
['emulators', '--help'],
|
||||
['simulators:control', '--help'],
|
||||
['emulator:control', '-h'],
|
||||
['emulators:control', '--help'],
|
||||
['simulator:control', '--help'],
|
||||
['sim:control', '-h'],
|
||||
['--help', 'simulator:start'],
|
||||
['--help', 'help', 'simulator:start'],
|
||||
['simulator:start', '--import', 'does-not-exist.json', '--help'],
|
||||
['simulator:control', '--import', 'does-not-exist.json', '--help'],
|
||||
];
|
||||
let results: { stdout: string; events: unknown[]; error?: string }[];
|
||||
const ordinaryHelp = [['account:login', '--help'], ['--help']];
|
||||
const literalHelp = ['simulator:control', 'clipboard.write', '--', '--help'];
|
||||
let results: {
|
||||
stdout: string;
|
||||
events: unknown[];
|
||||
error?: string;
|
||||
commandArgs?: string[];
|
||||
}[][];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
|
||||
{ timeout: 20_000 }
|
||||
results = await Promise.all(
|
||||
modes.map(async mode => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
fixture,
|
||||
root,
|
||||
JSON.stringify([...actionHelp, ...configHelp, ...ordinaryHelp, literalHelp]),
|
||||
mode,
|
||||
],
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
return JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
|
||||
})
|
||||
);
|
||||
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
|
||||
}, 30_000);
|
||||
|
||||
describe('simulator action help through real oclif lookup and hooks', () => {
|
||||
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
|
||||
'keeps canonical command identity and executes lifecycle hooks: $argv',
|
||||
({ index }) => {
|
||||
expect(results[index].error).toBeUndefined();
|
||||
expect(results[index].events).toEqual([
|
||||
describe.each(modes.map((mode, index) => ({ mode, index })))(
|
||||
'simulator help through real oclif routing ($mode)',
|
||||
({ index: mode }) => {
|
||||
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
|
||||
'keeps command identity and lifecycle hooks for action help: $argv',
|
||||
({ index }) => {
|
||||
expect(results[mode][index].error).toBeUndefined();
|
||||
expect(results[mode][index].events).toEqual([
|
||||
{ event: 'prerun', id: 'simulator:control' },
|
||||
{ event: 'run', id: 'simulator:control' },
|
||||
{ event: 'postrun', id: 'simulator:control' },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([...configHelp, ...ordinaryHelp].map((argv, index) => ({ argv, index })))(
|
||||
'prints help without executing a command: $argv',
|
||||
({ index }) => {
|
||||
const { stdout, events, error } = results[mode][index + actionHelp.length];
|
||||
expect(error).toBeUndefined();
|
||||
expect(stdout).toContain('USAGE');
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps --help after -- as a literal action value', () => {
|
||||
const result = results[mode].at(-1)!;
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.commandArgs).toEqual(['clipboard.write', '--', '--help']);
|
||||
expect(result.events).toEqual([
|
||||
{ event: 'prerun', id: 'simulator:control' },
|
||||
{ event: 'run', id: 'simulator:control' },
|
||||
{ event: 'postrun', id: 'simulator:control' },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
|
||||
'shows ordinary help without starting the command for an incomplete flag: $argv',
|
||||
({ index }) => {
|
||||
const { stdout, events, error } = results[index + actionHelp.length];
|
||||
expect(error).toBeUndefined();
|
||||
expect(stdout).toContain('USAGE');
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ describe(EasHelp, () => {
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
['simulators:control', 'rotate', '--help'],
|
||||
['emulator:control', 'rotate', '-h'],
|
||||
['emulators:control', 'rotate', '--help'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
|
||||
@@ -29,6 +32,7 @@ describe(EasHelp, () => {
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['sim:control', '-h'],
|
||||
['emulators:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
|
||||
@@ -202,6 +202,7 @@ describe('when already logged in', () => {
|
||||
{
|
||||
id: 'jane-id',
|
||||
name: 'jane',
|
||||
ownerUserActor: { id: 'user-id', username: 'jane' },
|
||||
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,6 +19,16 @@ function account(
|
||||
return { id: `${name}-id`, name, viewerUserPermission2: { permissions, effectivePermissions } };
|
||||
}
|
||||
|
||||
function personalAccount(
|
||||
permissions: Permission[],
|
||||
effectivePermissions: Permission[] = permissions
|
||||
): Actor['accounts'][number] {
|
||||
return {
|
||||
...account('jane', permissions, effectivePermissions),
|
||||
ownerUserActor: { id: 'user-id', username: 'jane' },
|
||||
};
|
||||
}
|
||||
|
||||
function userActor(accounts: Actor['accounts']): Actor {
|
||||
return {
|
||||
__typename: 'User',
|
||||
@@ -46,14 +56,14 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('does not list accounts when only the unlimited personal account exists', async () => {
|
||||
const lines = await runAsync(userActor([account('jane', ownerPermissions)]));
|
||||
const lines = await runAsync(userActor([personalAccount(ownerPermissions)]));
|
||||
expect(lines).toEqual([chalk.green('jane'), '[email protected]']);
|
||||
});
|
||||
|
||||
it('lists the role on each account', async () => {
|
||||
const lines = await runAsync(
|
||||
userActor([
|
||||
account('jane', ownerPermissions),
|
||||
personalAccount(ownerPermissions),
|
||||
account('acme', getMockViewerUserPermission(Role.Developer).permissions),
|
||||
account('other', [Permission.Admin, Permission.View]),
|
||||
])
|
||||
@@ -64,7 +74,7 @@ it('lists the role on each account', async () => {
|
||||
});
|
||||
|
||||
it('lists the personal account when the credentials limit it', async () => {
|
||||
const lines = await runAsync(userActor([account('jane', ownerPermissions, [Permission.View])]));
|
||||
const lines = await runAsync(userActor([personalAccount(ownerPermissions, [Permission.View])]));
|
||||
expect(lines).toContain(
|
||||
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
|
||||
);
|
||||
@@ -78,7 +88,7 @@ it('lists the personal account when the credentials limit it', async () => {
|
||||
it('shows the effective permissions when they do not match a role', async () => {
|
||||
const lines = await runAsync(
|
||||
userActor([
|
||||
account('jane', ownerPermissions),
|
||||
personalAccount(ownerPermissions),
|
||||
account('acme', getMockViewerUserPermission(Role.Admin).permissions, [
|
||||
Permission.Publish,
|
||||
Permission.View,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { DeviceRunSessionStatus } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import {
|
||||
createControlConnectionIdentityAsync,
|
||||
inspectControlConnectionAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../../simulator/controlConnection';
|
||||
import { ControlConnectionInfo } from '../../../simulator/controlConnectionProtocol';
|
||||
import { loadControlSessionAsync } from '../../../simulator/controlSession';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControlConnection from '../control/connection';
|
||||
|
||||
jest.mock('../../../simulator/controlConnection');
|
||||
jest.mock('../../../simulator/controlSession');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe('simulator:control:connection', () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
const identity = {
|
||||
root: '/private/ipc',
|
||||
socketPath: '/private/ipc/socket',
|
||||
scope: 'fixture',
|
||||
credentialHash: '0'.repeat(64),
|
||||
};
|
||||
const info: ControlConnectionInfo = {
|
||||
version: 3,
|
||||
pid: 123,
|
||||
session: {
|
||||
id: 'session-one',
|
||||
name: null,
|
||||
project: '@fixture/app',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
device: { udid: 'device-one', name: 'Fixture phone' },
|
||||
},
|
||||
busy: false,
|
||||
idleTimeoutMs: 120_000,
|
||||
idleDeadline: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(createControlConnectionIdentityAsync).mockResolvedValue(identity);
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValue(null);
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
});
|
||||
|
||||
function command(args: string[]): SimulatorControlConnection {
|
||||
const command = new SimulatorControlConnection(args, config);
|
||||
const context = {
|
||||
projectDir: '/project',
|
||||
loggedIn: {
|
||||
graphqlClient: {},
|
||||
authenticationInfo: { accessToken: 'credential-fixture', sessionSecret: null },
|
||||
},
|
||||
};
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(context);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('opens a connection with an idle timeout in seconds', async () => {
|
||||
jest.mocked(openControlConnectionAsync).mockImplementationOnce(async (_identity, load) => {
|
||||
await load();
|
||||
return info;
|
||||
});
|
||||
await command(['open', '--id', 'session-one', '--idle-timeout', '300', '--json']).runAsync();
|
||||
expect(openControlConnectionAsync).toHaveBeenCalledWith(
|
||||
identity,
|
||||
expect.any(Function),
|
||||
300_000
|
||||
);
|
||||
expect(inspectControlConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(loadControlSessionAsync).toHaveBeenCalledWith({}, 'session-one');
|
||||
});
|
||||
|
||||
it('requires a session ID when none is saved', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
try {
|
||||
await expect(command(['status']).runAsync()).rejects.toThrow('No simulator session ID');
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous !== undefined) {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('prints a useful message when status finds no connection', async () => {
|
||||
await command(['status', '--id', 'session-one']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'No control connection is open. Regular control commands are available.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([true, false])('prints connection details with busy=%s', async busy => {
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValueOnce({
|
||||
...info,
|
||||
busy,
|
||||
idleDeadline: Date.now() + 30_000,
|
||||
session: { ...info.session, device: { udid: 'device-one' } },
|
||||
});
|
||||
await command(['status', '--id', 'session-one']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith('iOS Simulator · session-one');
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Control connection ${busy ? 'busy' : 'ready'}`)
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching(/^Closes in /));
|
||||
});
|
||||
|
||||
it('acknowledges closing an existing connection in JSON and text', async () => {
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValue(info);
|
||||
await command(['close', '--id', 'session-one', '--json']).runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
connected: false,
|
||||
id: 'session-one',
|
||||
connection: null,
|
||||
});
|
||||
await command(['close', '--id', 'session-one']).runAsync();
|
||||
expect(Log.succeed).toHaveBeenCalledWith(
|
||||
'Control connection closing. The simulator session continues.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns JSON for a missing connection without opening one', async () => {
|
||||
await command(['status', '--id', 'session-one', '--json']).runAsync();
|
||||
expect(inspectControlConnectionAsync).toHaveBeenCalledWith(identity, { operation: 'status' });
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
connected: false,
|
||||
id: 'session-one',
|
||||
connection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('closing an absent connection succeeds without stopping a simulator session', async () => {
|
||||
await command(['close', '--id', 'session-one']).runAsync();
|
||||
expect(inspectControlConnectionAsync).toHaveBeenCalledWith(identity, { operation: 'close' });
|
||||
expect(Log.succeed).toHaveBeenCalledWith('No control connection is open.');
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['status', 'close'])('rejects idle-timeout for %s', async operation => {
|
||||
await expect(command([operation, '--idle-timeout', '300']).runAsync()).rejects.toThrow(
|
||||
'only to connection open'
|
||||
);
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['', '0', '-1', 'invalid', '5m', '0.5', '86401'])(
|
||||
'rejects idle timeout %j before connection setup',
|
||||
async duration => {
|
||||
await expect(
|
||||
command(['open', '--id', 'session-one', '--idle-timeout', duration]).runAsync()
|
||||
).rejects.toThrow('--idle-timeout');
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an empty id before loading account state', async () => {
|
||||
await expect(command(['open', '--id=']).runAsync()).rejects.toThrow('--id needs');
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the session saved in the project environment', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'saved-session';
|
||||
try {
|
||||
await command(['status']).runAsync();
|
||||
expect(createControlConnectionIdentityAsync).toHaveBeenCalledWith(
|
||||
'saved-session',
|
||||
'/project',
|
||||
expect.any(Object)
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import * as fetchModule from '../../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
@@ -13,17 +14,31 @@ import {
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { ora } from '../../../ora';
|
||||
import {
|
||||
BorrowedControlConnection,
|
||||
borrowControlConnectionAsync,
|
||||
createControlConnectionIdentityAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../../simulator/controlConnection';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
} from '../../../simulator/hostActions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControl from '../control';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/controlConnection', () => ({
|
||||
...jest.requireActual('../../../simulator/controlConnection'),
|
||||
borrowControlConnectionAsync: jest.fn(),
|
||||
createControlConnectionIdentityAsync: jest.fn(),
|
||||
openControlConnectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
@@ -31,6 +46,7 @@ jest.mock('../../../simulator/env', () => ({
|
||||
jest.mock('../../../simulator/hostActions', () => ({
|
||||
...jest.requireActual('../../../simulator/hostActions'),
|
||||
connectHostActionsAsync: jest.fn(),
|
||||
fetchHostActionDeviceAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -38,6 +54,7 @@ jest.mock('../../../ora', () => ({
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
@@ -107,6 +124,7 @@ describe(SimulatorControl, () => {
|
||||
let actionResult: HostActionResult;
|
||||
let served: HostActionCatalog;
|
||||
let tmpDir: string;
|
||||
let connectionController: AbortController;
|
||||
const requestAsync = jest.fn(
|
||||
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
|
||||
switch (action) {
|
||||
@@ -127,9 +145,25 @@ describe(SimulatorControl, () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(borrowControlConnectionAsync).mockResolvedValue(null);
|
||||
jest.mocked(createControlConnectionIdentityAsync).mockResolvedValue({
|
||||
root: '/private/ipc',
|
||||
socketPath: '/private/ipc/socket',
|
||||
scope: 'fixture',
|
||||
credentialHash: '0'.repeat(64),
|
||||
});
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockResolvedValue({ udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' });
|
||||
connectionController = new AbortController();
|
||||
mockConnectHostActionsAsync.mockResolvedValue({
|
||||
requestAsync,
|
||||
close,
|
||||
udid: 'simulator-udid',
|
||||
signal: connectionController.signal,
|
||||
});
|
||||
actionResult = ok('off\n');
|
||||
served = catalog;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
|
||||
@@ -143,7 +177,10 @@ describe(SimulatorControl, () => {
|
||||
const command = new SimulatorControl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: 'credential-fixture', sessionSecret: null },
|
||||
},
|
||||
projectDir,
|
||||
});
|
||||
return command;
|
||||
@@ -153,6 +190,312 @@ describe(SimulatorControl, () => {
|
||||
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
|
||||
}
|
||||
|
||||
function reuseConnection(): BorrowedControlConnection {
|
||||
const borrowed: BorrowedControlConnection = {
|
||||
connection: {
|
||||
requestAsync,
|
||||
close,
|
||||
udid: 'simulator-udid',
|
||||
signal: connectionController.signal,
|
||||
},
|
||||
catalog,
|
||||
info: {
|
||||
version: 3,
|
||||
pid: 123,
|
||||
idleTimeoutMs: 120_000,
|
||||
idleDeadline: null,
|
||||
busy: true,
|
||||
session: {
|
||||
id: 'session-123',
|
||||
name: 'Fixture session',
|
||||
project: '@testuser/testapp',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mocked(borrowControlConnectionAsync).mockResolvedValue(borrowed);
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
it('reuses the available connection without fetching the session or opening another remote socket', async () => {
|
||||
reuseConnection();
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--json',
|
||||
]).runAsync();
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'session-123', action: 'hardwareKeyboard.set', output: 'off' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an action connection alive with a custom timeout', async () => {
|
||||
const borrowed = reuseConnection();
|
||||
jest.mocked(openControlConnectionAsync).mockImplementationOnce(async (_identity, load) => {
|
||||
await load();
|
||||
return borrowed.info;
|
||||
});
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--keep-alive',
|
||||
'--idle-timeout',
|
||||
'300',
|
||||
]).runAsync();
|
||||
expect(openControlConnectionAsync).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
300_000
|
||||
);
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
});
|
||||
|
||||
it('can prepare keep-alive without running or listing actions', async () => {
|
||||
reuseConnection();
|
||||
await createCommand(['--keep-alive', '--id', 'session-123', '--json']).runAsync();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connected: true, id: 'session-123' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints readiness when preparing a connection without JSON', async () => {
|
||||
reuseConnection();
|
||||
await createCommand(['--keep-alive', '--id', 'session-123']).runAsync();
|
||||
expect(Log.succeed).toHaveBeenCalledWith(expect.stringContaining('Control connection ready'));
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not fall back to a cold connection if a newly opened helper disappears', async () => {
|
||||
await expect(createCommand(['--keep-alive', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'helper closed before it could be used'
|
||||
);
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects invalid timeout seconds before loading account or session state', async () => {
|
||||
const command = createCommand(['--keep-alive', '--idle-timeout', '0']);
|
||||
await expect(command.runAsync()).rejects.toThrow('--idle-timeout');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function writeImportAsync(actions: unknown[]): Promise<string> {
|
||||
const filePath = path.join(tmpDir, 'control.json');
|
||||
await fs.writeJson(filePath, { version: 1, actions });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it('shows the local file being uploaded during an import', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'app.install', params: {}, file: localFile },
|
||||
]);
|
||||
actionResult = ok('');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.gray).toHaveBeenCalledWith(` Read: ${localFile}`);
|
||||
expect(actionRequests().map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'app.install',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('imports an ordered config without prompts over the existing connection', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
]);
|
||||
await createCommand([
|
||||
'--import',
|
||||
filePath,
|
||||
'--id',
|
||||
'session-123',
|
||||
'--non-interactive',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([
|
||||
['hardwareKeyboard.set', { value: 'off' }],
|
||||
['hardwareKeyboard.set', { value: 'on' }],
|
||||
]);
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('iPhone 17 Pro · iOS 26.4'));
|
||||
expect(Log.log).toHaveBeenCalledWith(' off');
|
||||
const spinner = jest.mocked(ora).mock.results[1].value;
|
||||
expect(spinner.start).toHaveBeenCalledWith('[1/2] hardwareKeyboard.set');
|
||||
expect(spinner.start).toHaveBeenCalledWith('[2/2] hardwareKeyboard.set');
|
||||
expect(Log.succeed).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import complete · 2 actions')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'prints one JSON result for a successful imported batch (reused: %s)',
|
||||
async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"enabled":false}\n');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: { enabled: false }, file: null }],
|
||||
});
|
||||
expect(ora).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
expect(Log.succeed).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('retains completed results and failure metadata in JSON and stops the imported batch', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first completed'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Permission denied', exitCode: 1 });
|
||||
const previous = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: 'first completed', file: null }],
|
||||
error: {
|
||||
step: 2,
|
||||
action: 'hardwareKeyboard.set',
|
||||
message: expect.stringContaining('Permission denied'),
|
||||
},
|
||||
});
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.exitCode = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('preflights every imported step before running the first action', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'invalid' } },
|
||||
]);
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(ora).mock.results[1].value.fail).toHaveBeenCalledWith(
|
||||
'Validation failed. No actions were run.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: ['hardwareKeyboard.set', 'off'] },
|
||||
{ args: ['--list'] },
|
||||
{ args: ['--output', './out.png'] },
|
||||
])('rejects conflicting import options before connecting: %j', async ({ args }) => {
|
||||
await expect(
|
||||
createCommand(['--import', './control.json', '--id', 'session-123', ...args]).runAsync()
|
||||
).rejects.toThrow('--import cannot be combined');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses Android imports before connecting or reading the config file', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(
|
||||
createCommand(['--import', './missing.json', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('not supported yet');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints completed steps before a non-JSON import failure', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first done'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Native action failed', exitCode: 1 });
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(Log.log).toHaveBeenCalledWith(' first done');
|
||||
expect(Log.log).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(Log.gray).toHaveBeenCalledWith(' [3/3] hardwareKeyboard.set skipped');
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('downloads config-relative outputs and reports each saved file', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'screenshot.capture', output: './shot.png' },
|
||||
]);
|
||||
actionResult = ok('/remote/screenshot.png');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
const savedPath = path.join(tmpDir, 'shot.png');
|
||||
expect(await fs.readFile(savedPath, 'utf8')).toBe('png bytes');
|
||||
expect(Log.log).toHaveBeenCalledWith(` Saved: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(` Output: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
expect(jest.mocked(Log.gray).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestAsync.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('reports completion for actions with empty output', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(jest.mocked(ora).mock.results[1].value.succeed).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[1\/1\] hardwareKeyboard\.set /)
|
||||
);
|
||||
});
|
||||
|
||||
it('formats structured results without opening a picker', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"text":"Imported text"}');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(' {\n "text": "Imported text"\n }');
|
||||
});
|
||||
|
||||
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
@@ -186,6 +529,7 @@ describe(SimulatorControl, () => {
|
||||
it('runs an action from the catalog with its values and prints the output', async () => {
|
||||
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
|
||||
apiUrl: 'https://sim.example.test',
|
||||
@@ -196,8 +540,8 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
|
||||
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
|
||||
it('lists the actions of the session', async () => {
|
||||
await createCommand(['--list', '--id', 'session-123']).runAsync();
|
||||
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
|
||||
@@ -205,6 +549,7 @@ describe(SimulatorControl, () => {
|
||||
Object.keys(catalog).sort()
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
|
||||
@@ -278,31 +623,69 @@ describe(SimulatorControl, () => {
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: expect.objectContaining({ udid: 'simulator-udid' }),
|
||||
}),
|
||||
action: 'permissions.list',
|
||||
output: { voiceover: 'off' },
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a local file for an action that takes one and removes it after', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
it('prints plain text as a JSON string and lists a catalog as JSON', async () => {
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--json',
|
||||
]).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
project: '@testuser/testapp',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
action: 'hardwareKeyboard.set',
|
||||
output: 'off',
|
||||
file: null,
|
||||
});
|
||||
mockPrintJsonOnlyOutput.mockClear();
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockClear();
|
||||
await createCommand(['--list', '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'hardwareKeyboard.set': catalog['hardwareKeyboard.set'] })
|
||||
);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'uploads a local file for an action that takes one and removes it after (reused: %s)',
|
||||
async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('sends the upload ID to an action that asks for it', async () => {
|
||||
const localFile = path.join(tmpDir, 'session.json');
|
||||
await fs.writeFile(localFile, '{}');
|
||||
@@ -504,7 +887,10 @@ describe(SimulatorControl, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
it.each([false, true])('downloads the file an action returns (reused: %s)', async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n');
|
||||
|
||||
@@ -522,12 +908,42 @@ describe(SimulatorControl, () => {
|
||||
|
||||
it('refuses an action that the session does not list', async () => {
|
||||
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control --list` to see the actions.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ flags: [] }, { flags: ['--non-interactive'] }, { flags: ['--json'] }])(
|
||||
'lists actions by default with flags %j',
|
||||
async ({ flags }) => {
|
||||
await createCommand(['--id', 'session-123', ...flags]).runAsync();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
if (flags.includes('--json')) {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'app.install': expect.objectContaining({
|
||||
description: expect.stringContaining('host:/absolute/path'),
|
||||
}),
|
||||
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'For action usage: eas simulator:control <action> --help'
|
||||
);
|
||||
}
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects --output without an action before connecting', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', '--output', './out.png']).runAsync()
|
||||
).rejects.toThrow('--output requires an action');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an established connection when catalog setup fails', async () => {
|
||||
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
|
||||
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
@@ -536,6 +952,28 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows session and device details without terminal escape sequences', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({ name: '\x1b[31mCheckout QA\x1b[0m\nSpoofed\u202E' })
|
||||
);
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
const text = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([value]) => String(value))
|
||||
.join('\n');
|
||||
expect(text).toContain('Checkout QA Spoofed');
|
||||
expect(text).toContain('@testuser/testapp');
|
||||
expect(text).toContain('iPhone 17 Pro · iOS 26.4');
|
||||
expect(text).toContain('session-123');
|
||||
expect(text).toContain('simulator-udid');
|
||||
expect(text).not.toContain('\x1b[31m');
|
||||
expect(text).not.toContain('\u202E');
|
||||
expect(text).not.toContain('preview-token');
|
||||
});
|
||||
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
@@ -550,6 +988,100 @@ describe(SimulatorControl, () => {
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows fallback names when optional details are unavailable or blank', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession({ name: ' \n ' }));
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockResolvedValueOnce({ udid: 'simulator-udid' });
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unnamed session'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ importing: true, jsonFlag: false },
|
||||
{ importing: true, jsonFlag: true },
|
||||
{ importing: false, jsonFlag: true },
|
||||
])(
|
||||
'runs actions without display metadata (importing: $importing, json: $jsonFlag)',
|
||||
async ({ importing, jsonFlag }) => {
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/hostActions').fetchHostActionDeviceAsync
|
||||
);
|
||||
const fetch = jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockRejectedValueOnce(new Error('Metadata request failed'));
|
||||
try {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand([
|
||||
...(importing ? ['--import', filePath] : ['hardwareKeyboard.set', 'off']),
|
||||
...(jsonFlag ? ['--json'] : []),
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
if (jsonFlag) {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ device: { udid: 'simulator-udid' } }),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
}
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('requires a session ID before opening a connection', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
try {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(
|
||||
'No simulator session ID provided.'
|
||||
);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the session ID loaded into the simulator environment', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'dotenv-session';
|
||||
try {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-session');
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses Android before listing actions', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'not supported yet'
|
||||
);
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
|
||||
@@ -33,11 +33,14 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import { applyStartConfigAsync } from '../../../simulator/startSetup';
|
||||
import { ControlConfigExecutionError } from '../../../simulator/controlConfig';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../simulator/startSetup');
|
||||
jest.mock('../../../analytics/AnalyticsManager', () => ({
|
||||
...jest.requireActual('../../../analytics/AnalyticsManager'),
|
||||
isAnalyticsOptedOutAsync: jest.fn(),
|
||||
@@ -195,6 +198,7 @@ describe(Simulator, () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(applyStartConfigAsync).mockReset().mockResolvedValue([]);
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
@@ -203,7 +207,7 @@ describe(Simulator, () => {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockByIdAsync.mockReset().mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
@@ -238,6 +242,804 @@ describe(Simulator, () => {
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
describe('imported session configuration', () => {
|
||||
const configPath = '/test/simulator.json';
|
||||
const localEgressSession = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
},
|
||||
});
|
||||
function config(start: object = { platform: 'ios' }, actions?: object[]): void {
|
||||
jest
|
||||
.mocked(fs.readJson)
|
||||
.mockResolvedValue({ version: 1, start, ...(actions ? { actions } : {}) } as never);
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('enables JSON output before import preflight can fail', async () => {
|
||||
config({ unknown: true });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath, '--json']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(fs.readJson).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates from saved flags with explicit CLI overrides and unchanged output without actions', async () => {
|
||||
config({
|
||||
platform: 'ios',
|
||||
name: 'Saved name',
|
||||
type: 'web-preview-only',
|
||||
device: 'Saved phone',
|
||||
tag: ['saved'],
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--name', 'CLI name', '--json']);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
name: 'CLI name',
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
ios: { deviceIdentifier: 'Saved phone' },
|
||||
tags: ['saved'],
|
||||
})
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(printJsonOnlyOutput)).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ setup: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it.each(
|
||||
(['ios', 'android'] as const).flatMap(platform =>
|
||||
(['attached', 'detached', 'json'] as const).flatMap(mode =>
|
||||
(['env', 'dotenv'] as const).flatMap(output =>
|
||||
[false, true].map(egress => ({ platform, mode, output, egress }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)(
|
||||
'preserves ordinary startup with saved settings: $platform/$mode/$output, egress=$egress',
|
||||
async ({ platform, mode, output, egress }) => {
|
||||
const listeners = process.listeners('SIGINT');
|
||||
const start = {
|
||||
platform,
|
||||
type: 'web-preview-only',
|
||||
name: 'Startup regression',
|
||||
device: platform === 'ios' ? 'iPhone 17 Pro' : 'pixel_7',
|
||||
'out-config-type': output,
|
||||
...(egress ? { egress: 'local' } : {}),
|
||||
};
|
||||
const ready = makeDeviceRunSession({
|
||||
platform: platform === 'ios' ? AppPlatform.Ios : AppPlatform.Android,
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
...(egress
|
||||
? {
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const runAsync = async (saved: boolean): Promise<object> => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({ version: 1, start } as never);
|
||||
mockByIdAsync
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValue({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest.mocked(runLocalEgressAsync).mockImplementation(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const argv = saved
|
||||
? ['--import', configPath]
|
||||
: Object.entries(start).flatMap(([name, value]) => [`--${name}`, value]);
|
||||
if (mode !== 'attached') {
|
||||
argv.push(mode === 'json' ? '--json' : '--non-interactive');
|
||||
}
|
||||
await createCommand(argv).command.runAsync();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(fs.readJson).toHaveBeenCalledTimes(saved ? 1 : 0);
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledTimes(egress && mode === 'attached' ? 1 : 0);
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(
|
||||
output === 'dotenv' ? (mode === 'json' ? 1 : 2) : 0
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
return {
|
||||
create: mockCreateDeviceRunSessionAsync.mock.calls,
|
||||
output: jest.mocked(Log.log).mock.calls,
|
||||
warnings: jest.mocked(Log.warn).mock.calls,
|
||||
newLines: jest.mocked(Log.newLine).mock.calls,
|
||||
json: mockPrintJsonOnlyOutput.mock.calls,
|
||||
writes: jest.mocked(fs.writeFile).mock.calls,
|
||||
cleanup: mockResetSimulatorEnvAsync.mock.calls,
|
||||
stop: mockEnsureDeviceRunSessionStoppedAsync.mock.calls,
|
||||
egress: jest.mocked(runLocalEgressAsync).mock.calls.map(([options]) => ({
|
||||
url: options.url,
|
||||
port: options.port,
|
||||
platform: options.platform,
|
||||
aborted: options.signal.aborted,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const ordinary = await runAsync(false);
|
||||
expect(await runAsync(true)).toEqual(ordinary);
|
||||
}
|
||||
);
|
||||
|
||||
it('applies actions after readiness and includes parsed setup results in one JSON object', async () => {
|
||||
config(undefined, [{ action: 'appearance.set', params: { value: 'dark' } }]);
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
return [{ action: 'appearance.set', output: '{"value":"dark"}', file: null }];
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--json']);
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(applyStartConfigAsync).mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://sim.example.test', token: 'private-token' },
|
||||
expect.objectContaining({ path: configPath }),
|
||||
{ signal: expect.any(AbortSignal), onProgress: undefined }
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
setup: {
|
||||
config: configPath,
|
||||
results: [{ action: 'appearance.set', output: { value: 'dark' }, file: null }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'prints normal ready output before importing, detached=%s',
|
||||
async detached => {
|
||||
config({ platform: 'ios' }, [{ action: 'tap', params: { x: 0.5, y: 0.5 } }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
const importing = output.findIndex(line => line.includes('Importing 1 action from'));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(importing).toBeGreaterThan(preview);
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'running' });
|
||||
const result = { action: 'tap', output: '', file: null };
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'completed', result });
|
||||
return [result];
|
||||
});
|
||||
await createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(detached ? ['--non-interactive'] : []),
|
||||
]).command.runAsync();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining(`${EAS_SIMULATOR_SESSION_ID}='session-123'`)
|
||||
);
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.succeed.mock.calls.some(([text]: [string]) => text === '[1/1] tap completed')
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('identifies failed and skipped imported steps after printing the preview', async () => {
|
||||
config({ platform: 'ios' }, [
|
||||
{ action: 'appearance.get' },
|
||||
{ action: 'tap' },
|
||||
{ action: 'clipboard.read' },
|
||||
]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValueOnce(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'tap',
|
||||
[{ action: 'appearance.get', output: 'dark', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('refused');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(' [3/3] clipboard.read skipped');
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.fail.mock.calls.some(([text]: [string]) => text === '[2/3] tap failed')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('prints imported text and saved files after the ready preview URL', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }, { action: 'screenshot.capture' }]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
const results = [
|
||||
{ action: 'appearance.get', output: 'dark', file: null },
|
||||
{ action: 'screenshot.capture', output: '', file: '/test/screenshot.png' },
|
||||
];
|
||||
for (const [index, result] of results.entries()) {
|
||||
options.onProgress?.({
|
||||
step: index + 1,
|
||||
total: results.length,
|
||||
action: result.action,
|
||||
status: 'completed',
|
||||
result,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
});
|
||||
await createCommand(['--import', configPath, '--non-interactive']).command.runAsync();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(output.indexOf('dark')).toBeGreaterThan(preview);
|
||||
expect(output.indexOf('Saved /test/screenshot.png')).toBeGreaterThan(preview);
|
||||
});
|
||||
|
||||
it('preserves the setup error when the stopped session dotenv cannot be cleaned up', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValueOnce(new Error('native action refused'));
|
||||
mockResetSimulatorEnvAsync.mockRejectedValueOnce(new Error('read-only file'));
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('native action refused');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not be cleaned up: read-only file')
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops only the newly created session on setup failure and retains completed JSON results', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }, { action: 'appearance.get' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValue(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'appearance.get',
|
||||
[{ action: 'appearance.set', output: '"dark"', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
const prior = process.exitCode;
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
stopped: true,
|
||||
setup: expect.objectContaining({
|
||||
results: [{ action: 'appearance.set', output: 'dark', file: null }],
|
||||
error: expect.objectContaining({ step: 2, action: 'appearance.get' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ imported: false, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: false },
|
||||
])(
|
||||
'stops startup interrupted during its final dotenv write, imported=$imported, json=$jsonFlag',
|
||||
async ({ imported, jsonFlag }) => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
config(undefined, imported ? [{ action: 'appearance.get' }] : undefined);
|
||||
jest.mocked(fs.writeFile).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(jsonFlag ? ['--json'] : ['--non-interactive']),
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['interactive', 'detached', 'json'].flatMap(mode =>
|
||||
['failure', 'interruption'].map(outcome => ({ mode, outcome }))
|
||||
)
|
||||
)(
|
||||
'keeps previous session credentials after imported $outcome in $mode mode',
|
||||
async ({ mode, outcome }) => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
const previous =
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
"EAS_SIMULATOR_SESSION_ID='previous-session'\nEAS_SIMULATOR_EGRESS_TOKEN='previous-token'\n";
|
||||
let saved = previous;
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => saved as never);
|
||||
jest.mocked(fs.writeFile).mockImplementation(async (_path, content) => {
|
||||
saved = String(content);
|
||||
});
|
||||
mockResetSimulatorEnvAsync.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/env').resetSimulatorEnvAsync
|
||||
);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (outcome === 'interruption') {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw new Error('setup failed');
|
||||
});
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('interrupted');
|
||||
});
|
||||
const priorExitCode = process.exitCode;
|
||||
const listeners = process.listeners('SIGINT');
|
||||
try {
|
||||
const command = createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(mode === 'json' ? ['--json'] : mode === 'detached' ? ['--non-interactive'] : []),
|
||||
]).command;
|
||||
if (mode === 'json' && outcome === 'failure') {
|
||||
await command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} else {
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
outcome === 'interruption' ? 'interrupted' : 'setup failed'
|
||||
);
|
||||
}
|
||||
expect(saved).toBe(previous);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
jest.mocked(fs.readFile).mockReset();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('reports unconfirmed cleanup rather than implying the session stopped', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValue(new Error('setup failed'));
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('network failed'));
|
||||
const prior = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopped: false })
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('simulator:stop --id session-123')
|
||||
);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects Android actions and invalid files before creating a session', async () => {
|
||||
config({ platform: 'android' }, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('not supported on Android');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
config({ typo: 'value' });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath]);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects explicit capture fields when the CLI disables saved capture', async () => {
|
||||
config({ platform: 'ios', 'network-capture': true });
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
'--no-network-capture',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('--network-capture-field requires --network-capture');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Android start settings without actions', async () => {
|
||||
config({ platform: 'android' });
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ platform: AppPlatform.Android })
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the existing platform prompt and attached-session lifecycle in interactive mode', async () => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Ios });
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(mockPromptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Select platform' })
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'refuses an incomplete platform without prompting: %j',
|
||||
async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('platform flag must be set');
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['CI', 'pipe'])('inherits non-interactive defaults for %s', async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const priorCi = process.env.CI;
|
||||
const priorTty = process.stdin.isTTY;
|
||||
try {
|
||||
if (mode === 'CI') {
|
||||
process.env.CI = 'true';
|
||||
} else {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true });
|
||||
}
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'platform flag must be set'
|
||||
);
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (priorCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = priorCi;
|
||||
}
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: priorTty, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('connects interactive local egress before applying imported actions and closes it on session end', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.set' }]);
|
||||
const ready = localEgressSession;
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
let connected = false;
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
connected = true;
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(connected).toBe(true);
|
||||
return [];
|
||||
});
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'rejects actions with detached local egress before creating a session: %j',
|
||||
async mode => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('Imported actions with local egress require interactive mode');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('cleans up and preserves an egress connection failure before setup', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error('egress could not bind');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops setup instead of running without requested local egress settings', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'did not provide local egress connection settings'
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['action', 'egress'])(
|
||||
'preserves a %s failure during connected setup and closes the tunnel',
|
||||
async source => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error(`${source} failed during setup`);
|
||||
let rejectTunnel: (error: Error) => void = () => {};
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
tunnelClosed = true;
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (source === 'action') {
|
||||
throw failure;
|
||||
}
|
||||
rejectTunnel(failure);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new Error('action aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the wait for local egress and closes the tunnel on timeout', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const result = expect(
|
||||
createCommand(['--import', configPath]).command.runAsync()
|
||||
).rejects.toThrow('did not connect within 120 seconds');
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
await result;
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes local egress and stops the session on Ctrl-C before the tunnel connects', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const interrupted = new Error('exit 130');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
setImmediate(() => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
});
|
||||
tunnelClosed = true;
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
interrupted
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops on Ctrl-C during setup and removes the session handler', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
const interrupted = new Error('exit 130');
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementation(async (_access, _config, { signal }) => {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
return [];
|
||||
});
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toBe(interrupted);
|
||||
expect(exit).toHaveBeenCalledWith(130);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with the waitlist link without creating a session when the account is gated', async () => {
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
@@ -1831,7 +2633,14 @@ describe(Simulator, () => {
|
||||
'stops %s after an interactive egress failure and removes its interrupt handler',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
const failure = new Error('listen EADDRINUSE');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining(deviceRunSessionUrl));
|
||||
throw failure;
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
|
||||
@@ -11,9 +11,14 @@ import {
|
||||
formatSimulatorUnavailableMessage,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
export default class SimulatorAvailability extends EasCommand {
|
||||
static override aliases = ['sim:availability'];
|
||||
static override aliases = getSimulatorCommandAliases('availability');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('availability');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
|
||||
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
assertControlKeepAliveSupported,
|
||||
borrowControlConnectionAsync,
|
||||
createControlConnectionIdentityAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../simulator/controlConnection';
|
||||
import { ControlSession } from '../../simulator/controlConnectionProtocol';
|
||||
import { loadControlSessionAsync } from '../../simulator/controlSession';
|
||||
import {
|
||||
ControlAction,
|
||||
executeControlActionAsync,
|
||||
parseControlActionOutput,
|
||||
prepareControlActionAsync,
|
||||
} from '../../simulator/controlActions';
|
||||
import {
|
||||
ControlConfigExecutionError,
|
||||
ControlConfigResult,
|
||||
PreparedControlConfig,
|
||||
executeControlConfigAsync,
|
||||
prepareControlConfigAsync,
|
||||
serializeControlConfigResults,
|
||||
} from '../../simulator/controlConfig';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -21,21 +41,43 @@ import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../../simulator/hostActions';
|
||||
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sanitizeTerminalText, truncate } from '../../utils/terminalText';
|
||||
import { formatMilliseconds } from '../../utils/timer';
|
||||
|
||||
export default class SimulatorControl extends EasCommand {
|
||||
static override aliases = ['sim:control'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`';
|
||||
static override aliases = getSimulatorCommandAliases('control');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('control');
|
||||
static override description = `[EXPERIMENTAL] control a remote iOS simulator session on EAS
|
||||
|
||||
Run without an action to list available actions. Use <action> --help for usage.
|
||||
|
||||
Use --keep-alive to reuse the connection across commands, or prepare it with simulator:control:connection open. Connections close after 120 seconds idle by default; use --idle-timeout to change the timeout in seconds.
|
||||
|
||||
To save a screenshot locally, run \`eas simulator:control screenshot.capture --output ./screenshot.png\`.
|
||||
|
||||
Use --import to run the actions from a JSON file. The start section is used only by simulator:start:
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"start": {"device": "iPhone 17 Pro", "type": "web-preview-only"},
|
||||
"actions": [{"action": "appearance.set", "params": {"value": "dark"}}]
|
||||
}
|
||||
|
||||
The file is checked before any actions run. Actions run in order and stop on the first failure. Local paths are relative to the JSON file; paths in params refer to the simulator host.
|
||||
|
||||
Existing output files are overwritten. When saving to a directory, an existing file with the generated name causes the action to fail.`;
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
@@ -43,6 +85,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
'$ eas simulator:control --id <session-id>',
|
||||
'$ eas simulator:control screenshot.capture --help',
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control --import ./simulator-control.json',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.ipa',
|
||||
@@ -53,24 +96,36 @@ export default class SimulatorControl extends EasCommand {
|
||||
static override args = {
|
||||
action: Args.string({
|
||||
description:
|
||||
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.',
|
||||
'Action name, followed by values or name=value pairs. Omit to list actions. Use -- before values starting with --.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
help: Flags.boolean({
|
||||
char: 'h',
|
||||
description: 'Show command help, or usage for the selected action from the session.',
|
||||
description: 'Show help for this command or the selected action',
|
||||
}),
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
list: Flags.boolean({
|
||||
description: 'List the actions the session supports.',
|
||||
description: 'List available actions',
|
||||
}),
|
||||
output: Flags.string({
|
||||
description: 'Local file or existing directory for saved output',
|
||||
}),
|
||||
import: Flags.string({
|
||||
description: 'Run saved actions from a JSON file',
|
||||
}),
|
||||
'keep-alive': Flags.boolean({
|
||||
description:
|
||||
'Local path to save a returned file. For screenshots, --output ./screenshot.png sets the local filename; omit it to save with a generated filename in the current directory.',
|
||||
'Keep the control connection available for subsequent commands (macOS and Linux)',
|
||||
}),
|
||||
'idle-timeout': Flags.integer({
|
||||
min: 1,
|
||||
max: 86_400,
|
||||
description: 'Seconds of idle time before closing the kept-alive connection (default: 120)',
|
||||
dependsOn: ['keep-alive'],
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -90,11 +145,23 @@ export default class SimulatorControl extends EasCommand {
|
||||
if (flags.id === '') {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
const idleTimeoutMs =
|
||||
flags['idle-timeout'] !== undefined ? flags['idle-timeout'] * 1_000 : undefined;
|
||||
if (flags['keep-alive']) {
|
||||
assertControlKeepAliveSupported();
|
||||
}
|
||||
const action = flags.list && !flags.help ? undefined : args.action;
|
||||
const importing = flags.import !== undefined;
|
||||
if (importing && (args.action || flags.list || flags.output !== undefined)) {
|
||||
throw new Error('--import cannot be combined with an action, --list, or --output.');
|
||||
}
|
||||
if (!action && flags.output) {
|
||||
throw new Error('--output requires an action.');
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { graphqlClient, authenticationInfo },
|
||||
} = await this.getContextAsync(SimulatorControl, {
|
||||
nonInteractive,
|
||||
});
|
||||
@@ -111,41 +178,202 @@ export default class SimulatorControl extends EasCommand {
|
||||
let connection: HostActionConnection | undefined;
|
||||
try {
|
||||
let catalog: HostActionCatalog;
|
||||
let controlSession: ControlSession;
|
||||
try {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(assertControllableSession(session));
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
const identity = await createControlConnectionIdentityAsync(
|
||||
sessionId,
|
||||
projectDir,
|
||||
authenticationInfo
|
||||
);
|
||||
if (flags['keep-alive']) {
|
||||
await openControlConnectionAsync(
|
||||
identity,
|
||||
() => loadControlSessionAsync(graphqlClient, sessionId),
|
||||
idleTimeoutMs
|
||||
);
|
||||
}
|
||||
const borrowed = await borrowControlConnectionAsync(identity);
|
||||
if (flags['keep-alive'] && !borrowed) {
|
||||
throw new Error(
|
||||
'The control helper closed before it could be used. Open the connection again.'
|
||||
);
|
||||
}
|
||||
if (borrowed) {
|
||||
connection = borrowed.connection;
|
||||
catalog = borrowed.catalog;
|
||||
controlSession = borrowed.info.session;
|
||||
spinner.stop();
|
||||
if (flags['keep-alive'] && !action && !importing && !flags.list && !flags.help) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ connected: true, id: sessionId, connection: borrowed.info });
|
||||
} else {
|
||||
Log.succeed(
|
||||
`Control connection ready · idle timeout: ${formatMilliseconds(borrowed.info.idleTimeoutMs)}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { access, session } = await loadControlSessionAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(access);
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
controlSession = {
|
||||
...session,
|
||||
device:
|
||||
importing || (jsonFlag && Boolean(action) && !flags.list && !flags.help)
|
||||
? await fetchHostActionDeviceAsync(access, connection.udid)
|
||||
: { udid: connection.udid },
|
||||
};
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
|
||||
throw err;
|
||||
}
|
||||
if (!action) {
|
||||
if (!action && !importing) {
|
||||
this.printCatalog(catalog, jsonFlag);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
|
||||
);
|
||||
}
|
||||
if (flags.help) {
|
||||
if (flags.help && action) {
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control --list\` to see the actions.`
|
||||
);
|
||||
}
|
||||
this.printActionHelp(action, entry, jsonFlag);
|
||||
return;
|
||||
}
|
||||
await this.runActionAsync(connection, entry, {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
if (importing) {
|
||||
await this.importConfigAsync(connection, catalog, flags.import!, {
|
||||
session: controlSession,
|
||||
jsonFlag,
|
||||
});
|
||||
} else {
|
||||
const entry = Object.hasOwn(catalog, action!) ? catalog[action!] : undefined;
|
||||
await this.runActionAsync(
|
||||
connection,
|
||||
catalog,
|
||||
{
|
||||
action: action!,
|
||||
params: entry ? buildHostActionParams(action!, entry, argv.slice(1).map(String)) : {},
|
||||
outputPath: flags.output,
|
||||
},
|
||||
{ session: controlSession, jsonFlag }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async importConfigAsync(
|
||||
connection: HostActionConnection,
|
||||
catalog: HostActionCatalog,
|
||||
filePath: string,
|
||||
{ session, jsonFlag }: { session: ControlSession; jsonFlag: boolean }
|
||||
): Promise<void> {
|
||||
const spinner = jsonFlag ? undefined : ora(`Validating ${path.basename(filePath)}`).start();
|
||||
const startedAt = Date.now();
|
||||
let stepStartedAt = startedAt;
|
||||
let config: PreparedControlConfig | undefined;
|
||||
let results: ControlConfigResult[];
|
||||
try {
|
||||
config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
if (spinner) {
|
||||
spinner.succeed(`Validated ${formatActionCount(config.actions.length)}`);
|
||||
this.printSessionHeader(session);
|
||||
Log.log(chalk.bold(path.basename(config.path)));
|
||||
Log.gray(config.path);
|
||||
for (const { plan } of config.actions) {
|
||||
if (plan.localFile) {
|
||||
Log.gray(` Read: ${plan.localFile.path}`);
|
||||
}
|
||||
if (plan.outputPath) {
|
||||
Log.gray(` Output: ${plan.outputPath}`);
|
||||
}
|
||||
}
|
||||
if (config.actions.some(({ plan }) => plan.outputPath)) {
|
||||
Log.gray(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
const prepared = config;
|
||||
results = await executeControlConfigAsync(connection, prepared, {
|
||||
onProgress: spinner
|
||||
? progress => {
|
||||
const label = `[${progress.step}/${progress.total}] ${progress.action}`;
|
||||
if (progress.status === 'running') {
|
||||
stepStartedAt = Date.now();
|
||||
spinner.start(label);
|
||||
} else {
|
||||
spinner.succeed(`${label} ${chalk.dim(formatElapsed(stepStartedAt))}`);
|
||||
this.printConfigResult(progress.result);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ControlConfigExecutionError && config) {
|
||||
spinner?.fail(
|
||||
`[${error.step}/${config.actions.length}] ${error.action} ${chalk.dim(formatElapsed(stepStartedAt))}`
|
||||
);
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(error.completed),
|
||||
error: { step: error.step, action: error.action, message: error.message },
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const skipped = config.actions.length - error.step;
|
||||
Log.newLine();
|
||||
Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`);
|
||||
for (const [index, { plan }] of config.actions.entries()) {
|
||||
if (index >= error.step) {
|
||||
Log.gray(` [${index + 1}/${config.actions.length}] ${plan.action} skipped`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spinner?.fail(config ? 'Import failed' : 'Validation failed. No actions were run.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(results),
|
||||
});
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.succeed(
|
||||
`Import complete · ${formatActionCount(results.length)} · ${formatElapsed(startedAt)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printConfigResult(result: ControlConfigResult): void {
|
||||
if (result.file) {
|
||||
Log.log(` Saved: ${result.file}`);
|
||||
} else if (result.output) {
|
||||
const output = parseControlActionOutput(result.output);
|
||||
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
||||
Log.log(
|
||||
text
|
||||
.split('\n')
|
||||
.map(line => ` ${line}`)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(
|
||||
@@ -210,63 +438,59 @@ export default class SimulatorControl extends EasCommand {
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private printSessionHeader(session: ControlSession): void {
|
||||
const clean = (value: string): string =>
|
||||
sanitizeTerminalText(
|
||||
stripVTControlCharacters(value).replace(/[\u202A-\u202E\u2066-\u2069]/g, '')
|
||||
);
|
||||
const runtime = clean(session.device.runtime ?? '')
|
||||
.replace(/^([A-Za-z]+)-/, '$1 ')
|
||||
.replaceAll('-', '.');
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('Simulator controls'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Session', value: truncate(clean(session.name ?? '') || 'Unnamed session', 80) },
|
||||
{ label: 'Project', value: clean(session.project) },
|
||||
{
|
||||
label: 'Device',
|
||||
value: [truncate(clean(session.device.name ?? '') || 'Unavailable', 80), runtime]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
},
|
||||
{ label: 'State', value: session.status.toLowerCase().replaceAll('_', ' ') },
|
||||
{ label: 'Session ID', value: clean(session.id) },
|
||||
{ label: 'Device ID', value: clean(session.device.udid) },
|
||||
])
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private async runActionAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
{
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
outputPath,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
catalog: HostActionCatalog,
|
||||
selection: ControlAction,
|
||||
{ session, jsonFlag }: { session: ControlSession; jsonFlag: boolean }
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
const { action } = selection;
|
||||
const plan = await prepareControlActionAsync(catalog, selection);
|
||||
const spinner = ora(`Running ${action} on simulator session ${session.id}`).start();
|
||||
let output: string;
|
||||
let savedFile: string | null;
|
||||
try {
|
||||
if (localFile) {
|
||||
spinner.text = `Uploading ${localFile.path}`;
|
||||
const upload = await uploadFileAsync(connection, localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
spinner.text = `Running ${action} on simulator session ${sessionId}`;
|
||||
}
|
||||
const result = await connection.requestAsync(action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
|
||||
output = savedFile ? '' : result.stdout.trim();
|
||||
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
|
||||
({ output, file: savedFile } = await executeControlActionAsync(connection, plan));
|
||||
spinner.succeed(`Ran ${action} on simulator session ${session.id}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${session.id}`);
|
||||
throw err;
|
||||
} finally {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: sessionId,
|
||||
id: session.id,
|
||||
session,
|
||||
action,
|
||||
output: parseJsonOutput(output),
|
||||
output: parseControlActionOutput(output),
|
||||
file: savedFile,
|
||||
});
|
||||
return;
|
||||
@@ -295,73 +519,10 @@ function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEnt
|
||||
: entry;
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('host:')) {
|
||||
if (uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
function formatElapsed(startedAt: number): string {
|
||||
return formatMilliseconds(Date.now() - startedAt) || '<1s';
|
||||
}
|
||||
|
||||
function assertControllableSession(
|
||||
session: Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>
|
||||
): PreviewApiAccess {
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} runs on ${session.platform.toLowerCase()}. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
function formatActionCount(count: number): string {
|
||||
return `${count} action${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import Log from '../../../log';
|
||||
import {
|
||||
assertControlKeepAliveSupported,
|
||||
createControlConnectionIdentityAsync,
|
||||
inspectControlConnectionAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../../simulator/controlConnection';
|
||||
import { loadControlSessionAsync } from '../../../simulator/controlSession';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { sanitizeTerminalText } from '../../../utils/terminalText';
|
||||
import { formatMilliseconds } from '../../../utils/timer';
|
||||
|
||||
export default class SimulatorControlConnection extends EasCommand {
|
||||
static override description =
|
||||
'optionally keep a simulator control connection available between commands';
|
||||
static override args = {
|
||||
operation: Args.string({
|
||||
required: true,
|
||||
options: ['open', 'status', 'close'],
|
||||
description: 'Open, inspect or close the local control connection',
|
||||
}),
|
||||
};
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
'idle-timeout': Flags.integer({
|
||||
min: 1,
|
||||
max: 86_400,
|
||||
description: 'Seconds of idle time before closing the control connection (default: 120)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
static override examples = [
|
||||
'$ eas simulator:control:connection open --idle-timeout 300',
|
||||
'$ eas simulator:control:connection status --id <session-id> --json',
|
||||
'$ eas simulator:control:connection close',
|
||||
];
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(SimulatorControlConnection);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
if (flags.id === '') {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
if (flags['idle-timeout'] !== undefined && args.operation !== 'open') {
|
||||
throw new Error('--idle-timeout applies only to connection open.');
|
||||
}
|
||||
const idleTimeoutMs =
|
||||
flags['idle-timeout'] !== undefined ? flags['idle-timeout'] * 1_000 : undefined;
|
||||
assertControlKeepAliveSupported();
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient, authenticationInfo },
|
||||
} = await this.getContextAsync(SimulatorControlConnection, { nonInteractive });
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
'No simulator session ID provided. Pass --id, or run eas simulator:start first.'
|
||||
);
|
||||
}
|
||||
const identity = await createControlConnectionIdentityAsync(
|
||||
sessionId,
|
||||
projectDir,
|
||||
authenticationInfo
|
||||
);
|
||||
const info =
|
||||
args.operation === 'open'
|
||||
? await openControlConnectionAsync(
|
||||
identity,
|
||||
() => loadControlSessionAsync(graphqlClient, sessionId),
|
||||
idleTimeoutMs
|
||||
)
|
||||
: await inspectControlConnectionAsync(identity, {
|
||||
operation: args.operation === 'close' ? 'close' : 'status',
|
||||
});
|
||||
if (json) {
|
||||
printJsonOnlyOutput({
|
||||
connected: Boolean(info) && args.operation !== 'close',
|
||||
id: sessionId,
|
||||
connection: args.operation === 'close' ? null : info,
|
||||
});
|
||||
} else if (args.operation === 'close') {
|
||||
Log.succeed(
|
||||
info
|
||||
? 'Control connection closing. The simulator session continues.'
|
||||
: 'No control connection is open.'
|
||||
);
|
||||
} else if (!info) {
|
||||
Log.log('No control connection is open. Regular control commands are available.');
|
||||
} else {
|
||||
Log.log(
|
||||
`${sanitizeTerminalText(info.session.device.name ?? 'iOS Simulator')} · ${sessionId}`
|
||||
);
|
||||
Log.log(
|
||||
`Control connection ${info.busy ? 'busy' : 'ready'} · idle timeout: ${formatMilliseconds(info.idleTimeoutMs)}`
|
||||
);
|
||||
if (info.idleDeadline !== null) {
|
||||
Log.log(
|
||||
`Closes in ${formatMilliseconds(Math.max(0, info.idleDeadline - Date.now())) || '<1s'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
export default class SimulatorEgress extends EasCommand {
|
||||
static override aliases = ['sim:egress'];
|
||||
static override aliases = getSimulatorCommandAliases('egress');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('egress');
|
||||
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
|
||||
|
||||
static override flags = {
|
||||
|
||||
@@ -24,12 +24,17 @@ import {
|
||||
} from '../../simulator/events';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const POST_STOP_REFRESH_COUNT = 2;
|
||||
|
||||
export default class SimulatorEvents extends EasCommand {
|
||||
static override aliases = ['sim:events'];
|
||||
static override aliases = getSimulatorCommandAliases('events');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('events');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show activity events from a remote simulator session';
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ import spawnAsync from '@expo/spawn-async';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
export default class SimulatorExec extends EasCommand {
|
||||
static override aliases = ['sim:exec'];
|
||||
static override aliases = getSimulatorCommandAliases('exec');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('exec');
|
||||
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
|
||||
static override strict = false;
|
||||
|
||||
|
||||
@@ -32,12 +32,17 @@ import {
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override aliases = ['sim:get'];
|
||||
static override aliases = getSimulatorCommandAliases('get');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('get');
|
||||
static override description =
|
||||
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { Ora, ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import {
|
||||
SIMULATOR_HIDDEN_TOPIC_ALIASES,
|
||||
SIMULATOR_TOPIC_ALIASES,
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
import {
|
||||
ControlConfigExecutionError,
|
||||
serializeControlConfigResults,
|
||||
} from '../../simulator/controlConfig';
|
||||
import { resolveStartConfigAsync } from '../../simulator/startConfig';
|
||||
import { applyStartConfigAsync } from '../../simulator/startSetup';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import { parseCorsOriginList, parseFrameAncestorList } from '../../simulator/frameAncestors';
|
||||
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
|
||||
@@ -59,12 +71,15 @@ import {
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getPreviewApiAccess,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
// Allow the cold-cache client download (up to 60s) and a further minute to connect.
|
||||
const EGRESS_SETUP_TIMEOUT_MS = 120_000;
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue
|
||||
const OUT_CONFIG_TYPE_VALUES = {
|
||||
@@ -80,12 +95,37 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
};
|
||||
|
||||
export default class Simulator extends EasCommand {
|
||||
static override aliases = ['simulator:start', 'sim:start'];
|
||||
static override hiddenAliases = ['sim'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in';
|
||||
static override aliases = ['simulator:start', ...getSimulatorCommandAliases('start')];
|
||||
static override hiddenAliases = [
|
||||
...SIMULATOR_TOPIC_ALIASES,
|
||||
...SIMULATOR_HIDDEN_TOPIC_ALIASES,
|
||||
...getSimulatorCommandHiddenAliases('start'),
|
||||
];
|
||||
static override description = `[EXPERIMENTAL] start a remote simulator session on EAS
|
||||
|
||||
Preview links require sign-in by default.
|
||||
|
||||
Use --import to load saved start options and actions. CLI flags override saved options, including lists:
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"start": {"device": "iPhone 17 Pro", "type": "web-preview-only"},
|
||||
"actions": [{"action": "appearance.set", "params": {"value": "dark"}}]
|
||||
}
|
||||
|
||||
The start object uses CLI flag names. Saved actions run after the session is ready. Local action paths are relative to the JSON file. If an action fails, the CLI tries to stop the new session. Android supports saved start options, but not actions yet.
|
||||
|
||||
With local egress, actions wait for the connection and require interactive mode. In scripts, connect with simulator:egress, then run simulator:control --import separately.`;
|
||||
|
||||
static override examples = [
|
||||
'$ eas simulator:start --import ./simulator.json',
|
||||
'$ eas simulator:start --import ./simulator.json --platform ios --device "iPhone 17 Pro" --json',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
import: Flags.string({
|
||||
description: 'Load start options and actions from a JSON file',
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
@@ -151,6 +191,7 @@ export default class Simulator extends EasCommand {
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'network-capture': Flags.boolean({
|
||||
allowNo: true,
|
||||
description:
|
||||
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
|
||||
}),
|
||||
@@ -208,6 +249,7 @@ export default class Simulator extends EasCommand {
|
||||
default: OUT_CONFIG_TYPE_VALUES.Dotenv,
|
||||
})(),
|
||||
'public-link': Flags.boolean({
|
||||
allowNo: true,
|
||||
description:
|
||||
'Enable the simulator preview Share button and print a shareable link containing an access token. Anyone with the link can control the simulator. With --json, include previewApiUrl and its token for API clients when available.',
|
||||
}),
|
||||
@@ -222,12 +264,13 @@ export default class Simulator extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(Simulator);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
// Import preflight can fail before parsing; keep diagnostics off JSON stdout.
|
||||
if (this.argv.includes('--json')) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const imported = await resolveStartConfigAsync(this.argv, Simulator.flags);
|
||||
const { flags } = await this.parse(Simulator, imported.argv);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -297,6 +340,9 @@ export default class Simulator extends EasCommand {
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (networkCaptureFields.length && !flags['network-capture']) {
|
||||
throw new EasCommandError('--network-capture-field requires --network-capture.');
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
@@ -327,6 +373,18 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android && imported.config?.actions?.length) {
|
||||
throw new EasCommandError(
|
||||
'Imported control actions are not supported on Android yet. Remove actions to import only start settings, or use --platform ios.'
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractive && egress && imported.config?.actions?.length) {
|
||||
throw new EasCommandError(
|
||||
'Imported actions with local egress require interactive mode. Start without actions, connect eas simulator:egress, then run eas simulator:control --import with the same file.'
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -424,7 +482,9 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
sessionInterrupt = registerSessionInterrupt(deviceRunSessionId);
|
||||
const simulatorEnvWritten =
|
||||
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
!jsonFlag &&
|
||||
!imported.config?.actions?.length &&
|
||||
flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
? await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
})
|
||||
@@ -526,101 +586,270 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (sessionInterrupt.signal.aborted) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
sessionInterrupt.dispose();
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig: sanitizeRemoteConfigForJson(
|
||||
remoteConfig,
|
||||
deviceRunSessionPreviewUrl,
|
||||
flags['public-link'],
|
||||
{ frameAncestors, corsOrigins }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
privatePreviewUrl: deviceRunSessionPreviewUrl,
|
||||
publicLink: flags['public-link'],
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow, platform);
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
// The instructions above already tell the reader to run eas simulator:egress.
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// In interactive mode the egress client runs right here, for as long as the
|
||||
// session does. A session with a disconnected tunnel still bills, so Ctrl+C stops both.
|
||||
let egressPromise: Promise<void> | undefined;
|
||||
let egressError: Error | undefined;
|
||||
const egressAbortController = new AbortController();
|
||||
if (localEgress) {
|
||||
sessionInterrupt.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
egressAbortController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
let markEgressReady: () => void;
|
||||
const egressReady = new Promise<void>(resolve => {
|
||||
markEgressReady = resolve;
|
||||
});
|
||||
const onSessionAbort = (): void => {
|
||||
egressAbortController.abort();
|
||||
};
|
||||
const startLocalEgress = (): void => {
|
||||
if (localEgress && !nonInteractive) {
|
||||
sessionInterrupt.signal.addEventListener('abort', onSessionAbort, { once: true });
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
markEgressReady();
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
const writeReadySimulatorEnvAsync = async (): Promise<void> => {
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!jsonFlag && !imported.config?.actions?.length) {
|
||||
await writeReadySimulatorEnvAsync();
|
||||
}
|
||||
|
||||
if (sessionInterrupt.signal.aborted && !egressError) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonFlag) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
privatePreviewUrl: deviceRunSessionPreviewUrl,
|
||||
publicLink: flags['public-link'],
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
if (nonInteractive) {
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let setup:
|
||||
| { config: string; results: ReturnType<typeof serializeControlConfigResults> }
|
||||
| undefined;
|
||||
if (imported.config?.actions?.length) {
|
||||
const total = imported.config.actions.length;
|
||||
if (!jsonFlag) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Importing ${total} action${total === 1 ? '' : 's'} from ${imported.config.path}`
|
||||
);
|
||||
}
|
||||
startLocalEgress();
|
||||
const setupSpinner = ora('Importing simulator configuration').start();
|
||||
try {
|
||||
if (egress && !localEgress) {
|
||||
throw new Error('The ready session did not provide local egress connection settings.');
|
||||
}
|
||||
if (egressPromise) {
|
||||
setupSpinner.text = 'Waiting for local egress before applying configuration';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
egressReady,
|
||||
egressPromise,
|
||||
sessionInterrupt.abortPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error('Local egress did not connect within 120 seconds.'));
|
||||
}, EGRESS_SETUP_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
sessionInterrupt.signal.throwIfAborted();
|
||||
}
|
||||
const access = getPreviewApiAccess(remoteConfig);
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
'This session does not expose an authenticated preview API for control actions.'
|
||||
);
|
||||
}
|
||||
const results = await applyStartConfigAsync(access, imported.config, {
|
||||
signal: sessionInterrupt.signal,
|
||||
onProgress: jsonFlag
|
||||
? undefined
|
||||
: progress => {
|
||||
const label = `[${progress.step}/${progress.total}] ${progress.action}`;
|
||||
if (progress.status === 'running') {
|
||||
setupSpinner.start(label);
|
||||
} else {
|
||||
setupSpinner.succeed(`${label} completed`);
|
||||
if (progress.result.file) {
|
||||
Log.log(`Saved ${progress.result.file}`);
|
||||
} else if (progress.result.output) {
|
||||
Log.log(progress.result.output);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
sessionInterrupt.signal.throwIfAborted();
|
||||
setup = { config: imported.config.path, results: serializeControlConfigResults(results) };
|
||||
setupSpinner.succeed(
|
||||
`Import complete: ${total} action${total === 1 ? '' : 's'} completed`
|
||||
);
|
||||
} catch (caught) {
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
const error = egressError ?? caught;
|
||||
if (sessionInterrupt.signal.aborted && !egressError) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: setupSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof ControlConfigExecutionError) {
|
||||
setupSpinner.fail(`[${error.step}/${total}] ${error.action} failed`);
|
||||
if (!jsonFlag) {
|
||||
const skipped = total - error.step;
|
||||
Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`);
|
||||
for (const [index, action] of imported.config.actions.entries()) {
|
||||
if (index >= error.step) {
|
||||
Log.log(` [${index + 1}/${total}] ${action.action} skipped`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setupSpinner.fail('Simulator configuration import failed');
|
||||
}
|
||||
const stopped = await ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
if (stopped) {
|
||||
if (!jsonFlag) {
|
||||
Log.succeed('Stopped the simulator session after setup failed.');
|
||||
}
|
||||
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId).catch(error => {
|
||||
Log.warn(
|
||||
`The session stopped, but ${SIMULATOR_DOTENV_FILE_NAME} could not be cleaned up: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
Log.warn(
|
||||
`Could not confirm cleanup. Run eas simulator:stop --id ${deviceRunSessionId} to stop the session.`
|
||||
);
|
||||
}
|
||||
sessionInterrupt.dispose();
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
stopped,
|
||||
setup: {
|
||||
config: imported.config.path,
|
||||
results:
|
||||
error instanceof ControlConfigExecutionError
|
||||
? serializeControlConfigResults(error.completed)
|
||||
: [],
|
||||
error: {
|
||||
...(error instanceof ControlConfigExecutionError
|
||||
? { step: error.step, action: error.action }
|
||||
: {}),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag || imported.config?.actions?.length) {
|
||||
// Keep the previous session's saved credentials until setup succeeds.
|
||||
await writeReadySimulatorEnvAsync();
|
||||
if (sessionInterrupt.signal.aborted) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (jsonFlag) {
|
||||
sessionInterrupt.dispose();
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
...(setup ? { setup } : {}),
|
||||
remoteConfig: sanitizeRemoteConfigForJson(
|
||||
remoteConfig,
|
||||
deviceRunSessionPreviewUrl,
|
||||
flags['public-link'],
|
||||
{ frameAncestors, corsOrigins }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imported.config?.actions?.length) {
|
||||
startLocalEgress();
|
||||
}
|
||||
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
@@ -628,13 +857,14 @@ export default class Simulator extends EasCommand {
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
} finally {
|
||||
sessionInterrupt.signal.removeEventListener('abort', onSessionAbort);
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
} from '../../simulator/utils';
|
||||
import { fromNow } from '../../utils/date';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 100;
|
||||
@@ -51,7 +55,8 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
|
||||
);
|
||||
|
||||
export default class SimulatorList extends EasCommand {
|
||||
static override aliases = ['sim:list'];
|
||||
static override aliases = getSimulatorCommandAliases('list');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('list');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] list remote simulator sessions for the current project';
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
stripTerminalControlCharacters,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
// serve-sim keeps sending heartbeats while its log source restarts, so the stream alone cannot show a failure.
|
||||
const LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS = 10_000;
|
||||
@@ -47,7 +51,8 @@ interface SimulatorLogsSnapshot {
|
||||
}
|
||||
|
||||
export default class SimulatorLogs extends EasCommand {
|
||||
static override aliases = ['sim:logs'];
|
||||
static override aliases = getSimulatorCommandAliases('logs');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('logs');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show buffered logs from a remote iOS simulator session';
|
||||
|
||||
|
||||
@@ -14,9 +14,14 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override aliases = ['sim:stop'];
|
||||
static override aliases = getSimulatorCommandAliases('stop');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('stop');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
|
||||
@@ -102,8 +102,6 @@ export function parseSshArgv(rawArgv: readonly string[]): {
|
||||
}
|
||||
|
||||
export default class WorkflowSsh extends EasCommand {
|
||||
static override hidden = true;
|
||||
|
||||
static override description =
|
||||
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
|
||||
|
||||
|
||||
+2
-2
@@ -16824,7 +16824,7 @@ export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?:
|
||||
| { __typename: 'PartnerActor', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename: 'PartnerActor', id: string, username: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission2: { __typename?: 'ViewerUserPermission', permissions: Array<Permission>, effectivePermissions: Array<Permission> } }> }
|
||||
@@ -17022,7 +17022,7 @@ export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: st
|
||||
|
||||
export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number };
|
||||
|
||||
type MeActorFragment_PartnerActor = { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
type MeActorFragment_PartnerActor = { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission2: { __typename?: 'ViewerUserPermission', permissions: Array<Permission>, effectivePermissions: Array<Permission> } }> };
|
||||
|
||||
@@ -24,6 +24,9 @@ export const MeActorFragmentNode = gql`
|
||||
}
|
||||
... on PartnerActor {
|
||||
username
|
||||
primaryAccount {
|
||||
id
|
||||
}
|
||||
}
|
||||
featureGates
|
||||
isExpoAdmin
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Help, Parser } from '@oclif/core';
|
||||
|
||||
import { isSimulatorCommandId } from './simulator/commandAliases';
|
||||
|
||||
export default class EasHelp extends Help {
|
||||
// eslint-disable-next-line async-protect/async-suffix -- oclif defines the method name.
|
||||
override async showHelp(argv: string[]): Promise<void> {
|
||||
const [command, ...args] = argv;
|
||||
if (command === 'simulator:control' || command === 'sim:control') {
|
||||
if (isSimulatorCommandId(command, 'control')) {
|
||||
const { default: SimulatorControl } = await import('./commands/simulator/control');
|
||||
let parsed;
|
||||
try {
|
||||
|
||||
@@ -114,6 +114,44 @@ describe(getAccountChoices, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies the personal account by id when its name differs from the username', () => {
|
||||
const renamedActor = {
|
||||
...userActor,
|
||||
accounts: userActor.accounts.map(account =>
|
||||
account.id === 'account-id-personal' ? { ...account, name: 'jane-personal' } : account
|
||||
),
|
||||
} as Actor;
|
||||
|
||||
const choices = getAccountChoices(renamedActor);
|
||||
|
||||
expect(choices.map(choice => choice.title)).toEqual(['jane-personal', 'bob', 'acme']);
|
||||
expect(choices[0].description).toEqual('(Personal)');
|
||||
});
|
||||
|
||||
it('lists the primary account of a partner actor as personal', () => {
|
||||
const partnerActor = {
|
||||
__typename: 'PartnerActor',
|
||||
id: 'partner-actor-id',
|
||||
username: 'partner-user',
|
||||
primaryAccount: { id: 'account-id-partner' },
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-id-partner',
|
||||
name: 'partner-user',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
} as Actor;
|
||||
|
||||
const choices = getAccountChoices(partnerActor);
|
||||
|
||||
expect(choices.map(choice => choice.title)).toEqual(['partner-user']);
|
||||
expect(choices[0].description).toEqual('(Personal)');
|
||||
});
|
||||
|
||||
it('lists accounts without personal/team/organization annotations for robots', () => {
|
||||
const choices = getAccountChoices(robotActor);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SelectChoice } from '../prompts';
|
||||
import { Actor, getCannotCreateProjectsOnAccountReason } from '../user/User';
|
||||
import { Actor, getCannotCreateProjectsOnAccountReason, getPersonalAccount } from '../user/User';
|
||||
|
||||
function getDisabledProps(
|
||||
account: Actor['accounts'][number]
|
||||
@@ -11,15 +11,8 @@ function getDisabledProps(
|
||||
export function getAccountChoices(actor: Actor): SelectChoice[] {
|
||||
const allAccounts = actor.accounts;
|
||||
|
||||
const sortedAccounts =
|
||||
actor.__typename === 'Robot'
|
||||
? allAccounts
|
||||
: [...allAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
if (actor.__typename !== 'Robot') {
|
||||
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
|
||||
const personalAccount = getPersonalAccount(actor);
|
||||
|
||||
const personalAccountChoice = personalAccount
|
||||
? {
|
||||
@@ -31,7 +24,7 @@ export function getAccountChoices(actor: Actor): SelectChoice[] {
|
||||
: undefined;
|
||||
|
||||
const userAccounts = allAccounts
|
||||
?.filter(account => account.ownerUserActor && account.name !== actor.username)
|
||||
?.filter(account => account.ownerUserActor && account.id !== personalAccount?.id)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
@@ -40,7 +33,7 @@ export function getAccountChoices(actor: Actor): SelectChoice[] {
|
||||
}));
|
||||
|
||||
const organizationAccounts = allAccounts
|
||||
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
|
||||
?.filter(account => account.id !== personalAccount?.id && !account.ownerUserActor)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
@@ -53,12 +46,10 @@ export function getAccountChoices(actor: Actor): SelectChoice[] {
|
||||
choices = [personalAccountChoice];
|
||||
}
|
||||
|
||||
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
return [...choices, ...userAccounts, ...organizationAccounts];
|
||||
}
|
||||
|
||||
return sortedAccounts.map(account => ({
|
||||
return allAccounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
...getDisabledProps(account),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import SimulatorAvailability from '../../commands/simulator/availability';
|
||||
import SimulatorControl from '../../commands/simulator/control';
|
||||
import SimulatorEgress from '../../commands/simulator/egress';
|
||||
import SimulatorEvents from '../../commands/simulator/events';
|
||||
import SimulatorExec from '../../commands/simulator/exec';
|
||||
import SimulatorGet from '../../commands/simulator/get';
|
||||
import Simulator from '../../commands/simulator/index';
|
||||
import SimulatorList from '../../commands/simulator/list';
|
||||
import SimulatorLogs from '../../commands/simulator/logs';
|
||||
import SimulatorStop from '../../commands/simulator/stop';
|
||||
import { isSimulatorCommandId } from '../commandAliases';
|
||||
|
||||
describe('simulator command aliases', () => {
|
||||
it.each([
|
||||
['availability', SimulatorAvailability],
|
||||
['control', SimulatorControl],
|
||||
['egress', SimulatorEgress],
|
||||
['events', SimulatorEvents],
|
||||
['exec', SimulatorExec],
|
||||
['get', SimulatorGet],
|
||||
['list', SimulatorList],
|
||||
['logs', SimulatorLogs],
|
||||
['stop', SimulatorStop],
|
||||
])('runs simulator:%s from every simulator topic alias', (name, command) => {
|
||||
expect(command.aliases).toEqual([`sim:${name}`]);
|
||||
expect(command.hiddenAliases).toEqual([
|
||||
`simulators:${name}`,
|
||||
`emulator:${name}`,
|
||||
`emulators:${name}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs simulator:start from the bare topic aliases', () => {
|
||||
expect(Simulator.aliases).toEqual(['simulator:start', 'sim:start']);
|
||||
expect(Simulator.hiddenAliases).toEqual([
|
||||
'sim',
|
||||
'simulators',
|
||||
'emulator',
|
||||
'emulators',
|
||||
'simulators:start',
|
||||
'emulator:start',
|
||||
'emulators:start',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['simulator:control', 'sim:control', 'simulators:control', 'emulators:control'])(
|
||||
'matches %s as simulator:control',
|
||||
id => {
|
||||
expect(isSimulatorCommandId(id, 'control')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([undefined, 'simulator:list', 'control', 'device:control'])(
|
||||
'does not match %s as simulator:control',
|
||||
id => {
|
||||
expect(isSimulatorCommandId(id, 'control')).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeControlActionAsync, prepareControlActionAsync } from '../controlActions';
|
||||
import { HostActionCatalog, HostActionConnection, HostActionResult } from '../hostActions';
|
||||
|
||||
const catalog: HostActionCatalog = {
|
||||
settings: { type: 'object' },
|
||||
install: { upload: 'uploadId', uploadAs: 'id' },
|
||||
media: { upload: 'path' },
|
||||
camera: { upload: 'target', uploadWhen: { source: 'file' }, keepUpload: true },
|
||||
screenshot: { output: 'path' },
|
||||
download: { output: 'base64' },
|
||||
};
|
||||
const ok = (stdout = ''): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
|
||||
describe('control action preparation and execution', () => {
|
||||
let tmpDir: string;
|
||||
let controller: AbortController;
|
||||
let result: HostActionResult;
|
||||
let connection: HostActionConnection;
|
||||
let requestAsync: jest.Mock<Promise<HostActionResult>, [string, Record<string, unknown>?]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'control-actions-'));
|
||||
controller = new AbortController();
|
||||
result = ok(' on\n');
|
||||
requestAsync = jest.fn(async (action: string, params: Record<string, unknown> = {}) => {
|
||||
if (action === 'upload.append') {
|
||||
return ok(`/remote/staging/${params.uploadId}\n`);
|
||||
}
|
||||
if (action === 'upload.remove') {
|
||||
return ok();
|
||||
}
|
||||
if (action === 'file.readBase64') {
|
||||
return ok(Buffer.from('download bytes').toString('base64'));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
connection = {
|
||||
udid: '90858C9D-BC34-4A0A-93D4-E4939A681A72',
|
||||
signal: controller.signal,
|
||||
requestAsync,
|
||||
close: () => {
|
||||
controller.abort(new Error('closed'));
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(tmpDir, 'fixture.json'), 'file bytes');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('copies typed and empty values without parsing stdout', async () => {
|
||||
const params = { enabled: false, count: 0, text: '', nested: { values: ['original'] } };
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params });
|
||||
params.text = 'changed';
|
||||
expect(plan.params).toEqual({
|
||||
enabled: false,
|
||||
count: 0,
|
||||
text: '',
|
||||
nested: { values: ['original'] },
|
||||
});
|
||||
result = ok(' {"value":0}\n');
|
||||
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '{"value":0}',
|
||||
file: null,
|
||||
});
|
||||
expect(requestAsync).toHaveBeenCalledWith('settings', plan.params);
|
||||
result = ok('');
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '',
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown actions and inapplicable output before execution', async () => {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, { action: 'toString', params: {} })
|
||||
).rejects.toThrow('Unknown simulator action');
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, { action: 'settings', params: {}, outputPath: './out' })
|
||||
).rejects.toThrow('does not return a file');
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves explicit local files and outputs against the supplied directory', async () => {
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: 'fixture.json',
|
||||
},
|
||||
{ localFileMode: 'explicit', baseDir: tmpDir }
|
||||
);
|
||||
expect(plan.localFile).toEqual({ param: 'uploadId', path: path.join(tmpDir, 'fixture.json') });
|
||||
const output = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath: 'result.json',
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
);
|
||||
expect(output.outputPath).toBe(path.join(tmpDir, 'result.json'));
|
||||
});
|
||||
|
||||
it('preserves an explicitly requested output directory when resolving relative paths', async () => {
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath: 'missing/',
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow('does not exist');
|
||||
});
|
||||
|
||||
it('rejects ambiguous or inapplicable explicit uploads', async () => {
|
||||
for (const action of [
|
||||
{ action: 'settings', params: {} },
|
||||
{ action: 'camera', params: { source: 'webcam' } },
|
||||
{ action: 'install', params: { uploadId: 'already-supplied' } },
|
||||
]) {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
...action,
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing, empty and directory inputs before execution', async () => {
|
||||
await fs.writeFile(path.join(tmpDir, 'empty'), '');
|
||||
for (const file of ['missing', 'empty', '.']) {
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses explicit host paths and does not infer uploads in explicit mode', async () => {
|
||||
const localFile = path.join(tmpDir, 'fixture.json');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'media',
|
||||
params: { path: localFile },
|
||||
})
|
||||
).localFile?.path
|
||||
).toBe(localFile);
|
||||
const remotePath = path.join(tmpDir, 'remote-only');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'media',
|
||||
params: { path: `host:${remotePath}` },
|
||||
})
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'media',
|
||||
params: { path: 'missing-relative' },
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'media',
|
||||
params: { path: localFile },
|
||||
},
|
||||
{ localFileMode: 'explicit' }
|
||||
)
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'webcam', target: 'Built-in Camera' },
|
||||
})
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a direct relative upload against baseDir without rewriting caller parameters', async () => {
|
||||
const params = { path: './fixture.json' };
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{ action: 'media', params },
|
||||
{ baseDir: tmpDir }
|
||||
);
|
||||
expect(plan.localFile?.path).toBe(path.join(tmpDir, 'fixture.json'));
|
||||
await executeControlActionAsync(connection, plan);
|
||||
expect(requestAsync.mock.calls[1]).toEqual([
|
||||
'media',
|
||||
{ path: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}` },
|
||||
]);
|
||||
expect(params).toEqual({ path: './fixture.json' });
|
||||
});
|
||||
|
||||
(process.platform === 'win32' ? it.skip : it)(
|
||||
'rejects a named pipe before opening it for upload',
|
||||
async () => {
|
||||
const file = path.join(tmpDir, 'input.pipe');
|
||||
execFileSync('mkfifo', [file]);
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
})
|
||||
).rejects.toThrow('not a regular file');
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
(process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it)(
|
||||
'rejects a local file that cannot be read before upload',
|
||||
async () => {
|
||||
const file = path.join(tmpDir, 'fixture.json');
|
||||
await fs.chmod(file, 0);
|
||||
try {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EACCES' });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(file, 0o600);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('substitutes an upload ID without changing the plan and removes staging after success', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await executeControlActionAsync(connection, plan);
|
||||
const uploadId = requestAsync.mock.calls[0][1]?.uploadId;
|
||||
expect(uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.json$/);
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('file bytes').toString('base64'), first: true },
|
||||
],
|
||||
['install', { uploadId }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
expect(plan.params).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a successful camera upload but cleans it after a failed action', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'file' },
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await executeControlActionAsync(connection, plan);
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual(['upload.append', 'camera']);
|
||||
expect(requestAsync.mock.calls[1][1]).toEqual({
|
||||
source: 'file',
|
||||
target: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}`,
|
||||
});
|
||||
requestAsync.mockClear();
|
||||
result = { stdout: '', stderr: 'camera unavailable', exitCode: 1 };
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow('camera unavailable');
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'camera',
|
||||
'upload.remove',
|
||||
]);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans staging if validation rejects real uploaded parameters', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'file' },
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
const validateParams = jest.fn(() => {
|
||||
throw new Error('invalid uploaded path');
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan, { validateParams })).rejects.toThrow(
|
||||
'invalid uploaded path'
|
||||
);
|
||||
expect(validateParams).toHaveBeenCalledWith({
|
||||
source: 'file',
|
||||
target: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}`,
|
||||
});
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a failed upload without running the action', async () => {
|
||||
requestAsync.mockImplementation(async action =>
|
||||
action === 'upload.append' ? { stdout: '', stderr: 'staging full', exitCode: 1 } : ok()
|
||||
);
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow('staging full');
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves an action output and returns the path instead of raw bytes', async () => {
|
||||
result = ok(Buffer.from('file content').toString('base64'));
|
||||
const outputPath = path.join(tmpDir, 'output');
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath,
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '',
|
||||
file: outputPath,
|
||||
});
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('file content');
|
||||
});
|
||||
|
||||
it('removes staging if the output directory disappears after preparation', async () => {
|
||||
const outputDir = path.join(tmpDir, 'output');
|
||||
await fs.mkdir(outputDir);
|
||||
const plan = await prepareControlActionAsync(
|
||||
{
|
||||
snapshot: { upload: 'path', output: 'base64' },
|
||||
},
|
||||
{
|
||||
action: 'snapshot',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
outputPath: path.join(outputDir, 'snapshot.png'),
|
||||
}
|
||||
);
|
||||
await fs.remove(outputDir);
|
||||
result = ok(Buffer.from('png').toString('base64'));
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'snapshot',
|
||||
'upload.remove',
|
||||
]);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ stdout: 'failure on stdout', stderr: '', exitCode: 1 }, 'failure on stdout'],
|
||||
[{ stdout: '', stderr: '', exitCode: 7 }, 'exit code 7'],
|
||||
])(
|
||||
'reports server failure details without closing a usable connection',
|
||||
async (failure, message) => {
|
||||
result = failure as HostActionResult;
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params: {} });
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow(message);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not send work after a terminal connection loss', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params: {} });
|
||||
const error = new Error('connection lost');
|
||||
controller.abort(error);
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toBe(error);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeControlConfigAsync, prepareControlConfigAsync } from '../controlConfig';
|
||||
import { HostActionCatalog, HostActionConnection, HostActionResult } from '../hostActions';
|
||||
|
||||
const itWithChmod = process.platform === 'win32' || process.geteuid?.() === 0 ? it.skip : it;
|
||||
|
||||
const catalog: HostActionCatalog = {
|
||||
'keyboard.set': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, value: { enum: ['on', 'off'] } },
|
||||
required: ['udid', 'value'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
'file.push': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
uploadId: { type: 'string', pattern: '^[A-Za-z0-9._-]+$' },
|
||||
destination: { type: 'string' },
|
||||
},
|
||||
required: ['udid', 'uploadId', 'destination'],
|
||||
additionalProperties: false,
|
||||
upload: 'uploadId',
|
||||
uploadAs: 'id',
|
||||
},
|
||||
'camera.set': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
source: { enum: ['file', 'webcam'] },
|
||||
path: { type: 'string' },
|
||||
},
|
||||
required: ['udid', 'source', 'path'],
|
||||
upload: 'path',
|
||||
uploadWhen: { source: 'file' },
|
||||
keepUpload: true,
|
||||
},
|
||||
screenshot: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' } },
|
||||
required: ['udid'],
|
||||
additionalProperties: false,
|
||||
output: 'base64',
|
||||
},
|
||||
echo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
enabled: { type: 'boolean' },
|
||||
count: { type: 'number' },
|
||||
items: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['udid'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('simulator control configuration', () => {
|
||||
let folder: string;
|
||||
let filePath: string;
|
||||
const ok = (stdout = ''): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
const requestAsync = jest.fn(async (action: string): Promise<HostActionResult> => {
|
||||
if (action === 'upload.append') {
|
||||
return ok('/remote/staged.txt\n');
|
||||
}
|
||||
if (action === 'screenshot') {
|
||||
return ok(Buffer.from('png').toString('base64'));
|
||||
}
|
||||
return ok('done\n');
|
||||
});
|
||||
const connection: HostActionConnection = {
|
||||
requestAsync,
|
||||
close: jest.fn(),
|
||||
signal: new AbortController().signal,
|
||||
udid: 'owned-udid',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'control-config-'));
|
||||
filePath = path.join(folder, 'simulator.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(folder);
|
||||
});
|
||||
|
||||
async function writeConfig(actions: unknown[], rest = {}): Promise<void> {
|
||||
await fs.writeJson(filePath, { version: 1, actions, ...rest });
|
||||
}
|
||||
|
||||
it('preserves typed, omitted and empty values without changing the input', async () => {
|
||||
const params = { text: '', enabled: false, count: 0, items: ['first', 'second'] };
|
||||
await writeConfig([{ action: 'echo', params }, { action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
const results = await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
['echo', params],
|
||||
['echo', {}],
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ action: 'echo', output: 'done', file: null },
|
||||
{ action: 'echo', output: 'done', file: null },
|
||||
]);
|
||||
expect((await fs.readJson(filePath)).actions[0].params).toEqual(params);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: 2, actions: [{ action: 'echo' }] },
|
||||
{ version: 1, actions: [] },
|
||||
{ version: 1, actions: [{ action: 'echo', extra: true }] },
|
||||
{ version: 1, actions: [{ action: 'echo', params: [] }] },
|
||||
{ version: 1, actions: [{ action: 'echo', params: { udid: 'other-device' } }] },
|
||||
{ version: 1, actions: [{ action: 'echo' }], sessionId: 'other-session' },
|
||||
])('rejects an invalid config envelope: %j', async input => {
|
||||
await fs.writeJson(filePath, input);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports invalid JSON with its config path', async () => {
|
||||
await fs.writeFile(filePath, '{ invalid');
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
'simulator.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing config file', async () => {
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preflights later steps before running any action or upload', async () => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
{ action: 'keyboard.set', params: { value: 'invalid' } },
|
||||
]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*keyboard\.set/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unknown actions before any execution', async () => {
|
||||
await writeConfig([{ action: 'echo' }, { action: 'missing' }]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*missing/i
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an explicit output for path downloads before executing earlier steps', async () => {
|
||||
const withDownload: HostActionCatalog = {
|
||||
...catalog,
|
||||
'screenshot.capture': { ...catalog.screenshot, output: 'path' },
|
||||
};
|
||||
await writeConfig([{ action: 'echo' }, { action: 'screenshot.capture' }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(withDownload, filePath, connection.udid)
|
||||
).rejects.toThrow(/step 2.*screenshot\.capture.*output/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects mistyped parameters rather than coercing JSON', async () => {
|
||||
await writeConfig([{ action: 'echo', params: { count: '12' } }]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/count/i
|
||||
);
|
||||
});
|
||||
|
||||
it('uses JSON Schema 2020-12 and leaves schema defaults omitted', async () => {
|
||||
const withTuple: HostActionCatalog = {
|
||||
tuple: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
value: {
|
||||
type: 'array',
|
||||
prefixItems: [{ type: 'number' }, { type: 'string' }],
|
||||
items: false,
|
||||
},
|
||||
enabled: { type: 'boolean', default: true },
|
||||
},
|
||||
required: ['udid', 'value'],
|
||||
},
|
||||
};
|
||||
await writeConfig([{ action: 'tuple', params: { value: [7, 'text'] } }]);
|
||||
const config = await prepareControlConfigAsync(withTuple, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('tuple', { value: [7, 'text'] });
|
||||
await writeConfig([{ action: 'tuple', params: { value: ['wrong', 'text'] } }]);
|
||||
await expect(prepareControlConfigAsync(withTuple, filePath, connection.udid)).rejects.toThrow(
|
||||
/params\/value\/0/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to execute a plan checked for another simulator', async () => {
|
||||
await writeConfig([{ action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, 'another-udid');
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toThrow(
|
||||
'simulator changed'
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves explicit local files relative to the config and substitutes upload IDs', async () => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('upload.append', {
|
||||
uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/),
|
||||
data: Buffer.from('content').toString('base64'),
|
||||
first: true,
|
||||
});
|
||||
const call = requestAsync.mock.calls.find(([action]) => action === 'file.push');
|
||||
expect(call).toEqual([
|
||||
'file.push',
|
||||
{ destination: 'data.txt', uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/) },
|
||||
]);
|
||||
expect(requestAsync).toHaveBeenLastCalledWith('upload.remove', {
|
||||
uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps remote paths unchanged even when a similarly named local file exists', async () => {
|
||||
const localPath = path.join(folder, 'fixture.txt');
|
||||
await fs.writeFile(localPath, 'local content');
|
||||
await writeConfig([{ action: 'camera.set', params: { source: 'file', path: localPath } }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync.mock.calls).toEqual([['camera.set', { source: 'file', path: localPath }]]);
|
||||
});
|
||||
|
||||
it('resolves output paths relative to the config', async () => {
|
||||
await writeConfig([{ action: 'screenshot', output: './screen.png' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
const results = await executeControlConfigAsync(connection, config);
|
||||
expect(await fs.readFile(path.join(folder, 'screen.png'), 'utf8')).toBe('png');
|
||||
expect(results[0].file).toBe(path.join(folder, 'screen.png'));
|
||||
});
|
||||
|
||||
itWithChmod.each(['existing file', 'new file', 'download directory'])(
|
||||
'rejects an unwritable %s before running earlier actions',
|
||||
async kind => {
|
||||
const destination = path.join(folder, 'read-only');
|
||||
const output = kind === 'new file' ? path.join(destination, 'screen.png') : destination;
|
||||
if (kind === 'existing file') {
|
||||
await fs.writeFile(destination, 'existing image');
|
||||
await fs.chmod(destination, 0o444);
|
||||
} else {
|
||||
await fs.mkdir(destination);
|
||||
await fs.chmod(destination, 0o555);
|
||||
}
|
||||
const action = kind === 'download directory' ? 'download' : 'screenshot';
|
||||
const withDownload: HostActionCatalog = {
|
||||
...catalog,
|
||||
download: { ...catalog.screenshot, output: 'path' },
|
||||
};
|
||||
try {
|
||||
await writeConfig([{ action: 'echo' }, { action, output }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(withDownload, filePath, connection.udid)
|
||||
).rejects.toThrow(/step 2.*(EACCES|permission)/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['app.install', 'media.add'])(
|
||||
'rejects an explicit file combined with another source for %s',
|
||||
async action => {
|
||||
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
|
||||
await writeConfig([{ action, params: { uploadId: 'old.ipa' }, file: './new.ipa' }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(realCatalog, filePath, connection.udid)
|
||||
).rejects.toThrow(/file.*source/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
itWithChmod(
|
||||
'rejects a new output in a write-only parent before running earlier actions',
|
||||
async () => {
|
||||
const destination = path.join(folder, 'write-only');
|
||||
await fs.mkdir(destination);
|
||||
await fs.chmod(destination, 0o222);
|
||||
try {
|
||||
await writeConfig([
|
||||
{ action: 'echo' },
|
||||
{ action: 'screenshot', output: path.join(destination, 'screen.png') },
|
||||
]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*(EACCES|permission)/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a denied output permission check before running earlier actions', async () => {
|
||||
await writeConfig([
|
||||
{ action: 'echo' },
|
||||
{ action: 'screenshot', output: path.join(folder, 'screen.png') },
|
||||
]);
|
||||
const access = jest.spyOn(fs, 'access').mockImplementationOnce(async () => {
|
||||
throw Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
||||
});
|
||||
try {
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*Permission denied/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
access.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an explicit file as the only source of a union action', async () => {
|
||||
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
|
||||
await writeConfig([{ action: 'app.install', file: './new.ipa' }]);
|
||||
const config = await prepareControlConfigAsync(realCatalog, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('app.install', { path: '/remote/staged.txt' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ action: 'echo', file: './fixture.txt' },
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './missing.txt' },
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: '.' },
|
||||
{ action: 'camera.set', params: { source: 'webcam', path: 'camera' }, file: './fixture.txt' },
|
||||
{
|
||||
action: 'file.push',
|
||||
params: { destination: 'data.txt', uploadId: 'already-staged' },
|
||||
file: './fixture.txt',
|
||||
},
|
||||
{ action: 'screenshot', output: './missing/screen.png' },
|
||||
])('rejects invalid file/output plans before execution: %j', async action => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([action]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops on the first runtime error and reports completed steps', async () => {
|
||||
await writeConfig([
|
||||
{ action: 'echo', params: { text: 'first' } },
|
||||
{ action: 'keyboard.set', params: { value: 'off' } },
|
||||
{ action: 'echo', params: { text: 'third' } },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
requestAsync.mockResolvedValueOnce(ok('first')).mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: 'device stopped',
|
||||
exitCode: 1,
|
||||
});
|
||||
const onProgress = jest.fn();
|
||||
await expect(
|
||||
executeControlConfigAsync(connection, config, { onProgress })
|
||||
).rejects.toMatchObject({
|
||||
step: 2,
|
||||
action: 'keyboard.set',
|
||||
completed: [{ action: 'echo', output: 'first', file: null }],
|
||||
});
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
['echo', { text: 'first' }],
|
||||
['keyboard.set', { value: 'off' }],
|
||||
]);
|
||||
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
|
||||
{ step: 1, total: 3, action: 'echo', status: 'running' },
|
||||
{
|
||||
step: 1,
|
||||
total: 3,
|
||||
action: 'echo',
|
||||
status: 'completed',
|
||||
result: { action: 'echo', output: 'first', file: null },
|
||||
},
|
||||
{ step: 2, total: 3, action: 'keyboard.set', status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves a non-Error failure without retrying the action', async () => {
|
||||
await writeConfig([{ action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
requestAsync.mockRejectedValueOnce('connection lost');
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toMatchObject({
|
||||
step: 1,
|
||||
completed: [],
|
||||
message: expect.stringContaining('connection lost'),
|
||||
});
|
||||
expect(requestAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates the actual uploaded value before invoking an action', async () => {
|
||||
const strict: HostActionCatalog = {
|
||||
'file.push': {
|
||||
...catalog['file.push'],
|
||||
uploadAs: 'path',
|
||||
properties: {
|
||||
...catalog['file.push'].properties,
|
||||
uploadId: { type: 'string', pattern: '^/tmp/serve-sim-uploads/' },
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(strict, filePath, connection.udid);
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toThrow(/uploadId/);
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,215 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { ChildProcess, fork } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Socket, createConnection, createServer } from 'node:net';
|
||||
|
||||
import { DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import {
|
||||
assertControlKeepAliveSupported,
|
||||
borrowControlConnectionAsync,
|
||||
createControlConnectionIdentityAsync,
|
||||
inspectControlConnectionAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../controlConnection';
|
||||
import {
|
||||
CONTROL_BOOTSTRAP_TIMEOUT_MS,
|
||||
CONTROL_SETUP_TIMEOUT_MS,
|
||||
ControlConnectionChannel,
|
||||
ControlConnectionInfo,
|
||||
} from '../controlConnectionProtocol';
|
||||
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
fork: jest.fn(),
|
||||
}));
|
||||
jest.mock('node:net', () => {
|
||||
const actual = jest.requireActual<typeof import('node:net')>('node:net');
|
||||
return { ...actual, createConnection: jest.fn(actual.createConnection) };
|
||||
});
|
||||
|
||||
describe('control connection client failures', () => {
|
||||
let dir: string;
|
||||
let identity: Awaited<ReturnType<typeof createControlConnectionIdentityAsync>>;
|
||||
const info: ControlConnectionInfo = {
|
||||
version: 3,
|
||||
pid: 123,
|
||||
session: {
|
||||
id: 'session-one',
|
||||
name: null,
|
||||
project: '@fixture/app',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
device: { udid: 'device-one' },
|
||||
},
|
||||
idleTimeoutMs: 120_000,
|
||||
idleDeadline: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp('/tmp/ctl-client-');
|
||||
identity = await createControlConnectionIdentityAsync(
|
||||
'session-one',
|
||||
dir,
|
||||
{ accessToken: 'fixture', sessionSecret: null },
|
||||
dir
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
jest
|
||||
.mocked(createConnection)
|
||||
.mockImplementation(jest.requireActual('node:net').createConnection);
|
||||
await fs.remove(dir);
|
||||
});
|
||||
|
||||
it('keeps cold commands available on Windows while refusing keep-alive', async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
try {
|
||||
expect(assertControlKeepAliveSupported).toThrow('macOS and Linux');
|
||||
await expect(borrowControlConnectionAsync(identity)).resolves.toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', original);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a nonempty credential for helper discovery', async () => {
|
||||
await expect(
|
||||
createControlConnectionIdentityAsync('session-one', dir, {
|
||||
accessToken: '',
|
||||
sessionSecret: null,
|
||||
})
|
||||
).rejects.toThrow('Sign in');
|
||||
});
|
||||
|
||||
it.each(['error', 'exit', 'timeout'] as const)(
|
||||
'cleans up failed bootstrap after a child %s',
|
||||
async failure => {
|
||||
let sent!: () => void;
|
||||
const sentBootstrap = new Promise<void>(resolve => {
|
||||
sent = resolve;
|
||||
});
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
connected: true,
|
||||
exitCode: null as number | null,
|
||||
signalCode: null,
|
||||
send: jest.fn(() => {
|
||||
sent();
|
||||
if (failure === 'exit') {
|
||||
child.exitCode = 1;
|
||||
}
|
||||
if (failure !== 'timeout') {
|
||||
child.emit(failure, failure === 'error' ? new Error('spawn failed') : 1);
|
||||
}
|
||||
}),
|
||||
kill: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
unref: jest.fn(),
|
||||
});
|
||||
jest.mocked(fork).mockReturnValueOnce(child as unknown as ChildProcess);
|
||||
const opening = openControlConnectionAsync(identity, async () => {
|
||||
if (failure === 'timeout') {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
}
|
||||
return {
|
||||
access: { apiUrl: 'http://fixture.invalid', token: 'fixture' },
|
||||
session: info.session,
|
||||
};
|
||||
});
|
||||
const rejected = expect(opening).rejects.toThrow(
|
||||
failure === 'error'
|
||||
? 'Could not start'
|
||||
: failure === 'exit'
|
||||
? 'exited during setup'
|
||||
: 'did not become ready'
|
||||
);
|
||||
await sentBootstrap;
|
||||
if (failure === 'timeout') {
|
||||
jest.advanceTimersByTime(CONTROL_BOOTSTRAP_TIMEOUT_MS);
|
||||
}
|
||||
await rejected;
|
||||
expect(child.kill).toHaveBeenCalledTimes(failure === 'exit' ? 0 : 1);
|
||||
expect(child.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(child.unref).toHaveBeenCalledTimes(1);
|
||||
expect(await fs.pathExists(identity.socketPath + '.lock')).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['timeout', 'EACCES'] as const)(
|
||||
'rejects an unavailable local transport (%s) without silently running cold',
|
||||
async failure => {
|
||||
const server = createServer();
|
||||
await new Promise<void>(resolve => server.listen(identity.socketPath, resolve));
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
const socket = new Socket();
|
||||
let connecting!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
connecting = resolve;
|
||||
});
|
||||
jest.mocked(createConnection).mockImplementationOnce(() => {
|
||||
connecting();
|
||||
return socket;
|
||||
});
|
||||
try {
|
||||
const pending = inspectControlConnectionAsync(identity);
|
||||
const rejected = expect(pending).rejects.toThrow(
|
||||
failure === 'timeout' ? 'timed out' : 'denied'
|
||||
);
|
||||
await started;
|
||||
if (failure === 'timeout') {
|
||||
jest.advanceTimersByTime(CONTROL_SETUP_TIMEOUT_MS);
|
||||
} else {
|
||||
socket.emit('error', Object.assign(new Error('denied'), { code: 'EACCES' }));
|
||||
}
|
||||
await rejected;
|
||||
expect(socket.destroyed).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
jest.useRealTimers();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('serves the cached catalog and refuses a mismatched reply without replaying', async () => {
|
||||
const requests: unknown[] = [];
|
||||
const catalog = { 'clipboard.read': { type: 'object' } };
|
||||
const server = createServer(socket => {
|
||||
const channel = new ControlConnectionChannel(socket);
|
||||
void (async () => {
|
||||
await channel.readAsync();
|
||||
await channel.writeAsync({ info, catalog });
|
||||
requests.push(await channel.readAsync());
|
||||
await channel.writeAsync({ id: 99, stdout: '', stderr: '', exitCode: 0 });
|
||||
})().catch(error => {
|
||||
channel.destroy(error);
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(identity.socketPath, resolve));
|
||||
const borrowed = await borrowControlConnectionAsync(identity);
|
||||
try {
|
||||
expect(borrowed).not.toBeNull();
|
||||
await expect(borrowed!.connection.requestAsync('actions.describe')).resolves.toMatchObject({
|
||||
stdout: JSON.stringify(catalog),
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
await expect(borrowed!.connection.requestAsync('clipboard.read')).rejects.toThrow(
|
||||
'did not match'
|
||||
);
|
||||
expect(requests).toEqual([{ id: 1, action: 'clipboard.read', params: {} }]);
|
||||
} finally {
|
||||
borrowed?.connection.close();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Socket } from 'node:net';
|
||||
|
||||
import { ControlConnectionChannel, MAX_CONTROL_FRAME_BYTES } from '../controlConnectionProtocol';
|
||||
|
||||
describe('local control protocol', () => {
|
||||
let socket: Socket;
|
||||
let channel: ControlConnectionChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
socket = new Socket();
|
||||
channel = new ControlConnectionChannel(socket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
channel.destroy();
|
||||
});
|
||||
|
||||
it('decodes fragmented UTF-8 and returns queued replies in order', async () => {
|
||||
const bytes = Buffer.from(JSON.stringify({ text: 'héllo' }) + '\n{"id":2}\n');
|
||||
const split = bytes.indexOf(Buffer.from('é')) + 1;
|
||||
socket.emit('data', bytes.subarray(0, split));
|
||||
socket.emit('data', bytes.subarray(split));
|
||||
await expect(channel.readAsync()).resolves.toEqual({ text: 'héllo' });
|
||||
await expect(channel.readAsync()).resolves.toEqual({ id: 2 });
|
||||
});
|
||||
|
||||
it('rejects overlapping reads without replacing the first reader', async () => {
|
||||
const pending = channel.readAsync();
|
||||
await expect(channel.readAsync()).rejects.toThrow('already pending');
|
||||
socket.emit('data', Buffer.from('{"id":1}\n'));
|
||||
await expect(pending).resolves.toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it.each(['not-json\n', '{}\n'.repeat(9)])(
|
||||
'discards malformed or excessive queued input',
|
||||
async input => {
|
||||
socket.emit('data', Buffer.from(input));
|
||||
await expect(channel.readAsync()).rejects.toThrow('Invalid or oversized');
|
||||
await expect(channel.writeAsync({ id: 1 })).rejects.toThrow('Invalid or oversized');
|
||||
expect(socket.destroyed).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an oversized incoming frame before parsing it', async () => {
|
||||
const chunk = Buffer.alloc(1024 * 1024, 'x');
|
||||
for (let bytes = 0; bytes <= MAX_CONTROL_FRAME_BYTES; bytes += chunk.length) {
|
||||
socket.emit('data', chunk);
|
||||
}
|
||||
await expect(channel.readAsync()).rejects.toThrow('Invalid or oversized');
|
||||
expect(socket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an oversized outgoing frame without writing it', async () => {
|
||||
const write = jest.spyOn(socket, 'write');
|
||||
await expect(channel.writeAsync('x'.repeat(MAX_CONTROL_FRAME_BYTES))).rejects.toThrow(
|
||||
'too large'
|
||||
);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the first transport error for pending and later reads', async () => {
|
||||
const pending = channel.readAsync();
|
||||
const rejected = expect(pending).rejects.toThrow('transport failed');
|
||||
socket.emit('error', new Error('transport failed'));
|
||||
channel.destroy();
|
||||
await rejected;
|
||||
await expect(channel.readAsync()).rejects.toThrow('transport failed');
|
||||
});
|
||||
|
||||
it('propagates write callback failures', async () => {
|
||||
await expect(channel.writeAsync({ id: 1 })).rejects.toMatchObject({
|
||||
code: 'ERR_SOCKET_CLOSED',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import { getEventListeners, once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as fetchModule from '../../fetch';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
@@ -173,6 +174,108 @@ describe(formatHostActionUsage, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchHostActionDeviceAsync, () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('selects the connected device from the authenticated metadata catalog', async () => {
|
||||
const fetch = jest.spyOn(fetchModule, 'default').mockResolvedValueOnce(
|
||||
new fetchModule.Response(
|
||||
JSON.stringify({
|
||||
devices: [
|
||||
{ device: 'other-device', name: 'Other Simulator' },
|
||||
{ device: UDID, name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync(
|
||||
{ apiUrl: 'https://sim.example.test/preview/', token: TOKEN },
|
||||
UDID
|
||||
)
|
||||
).resolves.toEqual({
|
||||
udid: UDID,
|
||||
name: 'iPhone 17 Pro',
|
||||
runtime: 'iOS-26-4',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://sim.example.test/preview/grid/api/catalog',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Error('Metadata request failed'),
|
||||
new fetchModule.RequestError(
|
||||
'Request failed: 404 (Not Found)',
|
||||
new fetchModule.Response(undefined, { status: 404 })
|
||||
),
|
||||
Object.assign(new Error('Request timed out'), { name: 'AbortError' }),
|
||||
])('keeps the connected device ID when metadata fails: %s', async error => {
|
||||
jest.spyOn(fetchModule, 'default').mockRejectedValueOnce(error);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
});
|
||||
|
||||
it.each(['not JSON', JSON.stringify({ devices: null })])(
|
||||
'keeps the connected device ID when metadata is invalid: %s',
|
||||
async body => {
|
||||
jest.spyOn(fetchModule, 'default').mockResolvedValueOnce(new fetchModule.Response(body));
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects insecure metadata URLs before sending the session token', async () => {
|
||||
const fetch = jest.spyOn(fetchModule, 'default');
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'http://sim.example.test', token: TOKEN }, UDID)
|
||||
).rejects.toThrow('Simulator actions require HTTPS');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a device catalog to take more than three seconds during cold startup', async () => {
|
||||
let responseTimer: NodeJS.Timeout | undefined;
|
||||
const server = http.createServer((_request, response) => {
|
||||
responseTimer = setTimeout(() => {
|
||||
response.end(JSON.stringify({ devices: [{ device: UDID, name: 'iPhone 17 Pro' }] }));
|
||||
}, 3500);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address() as AddressInfo;
|
||||
try {
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: `http://127.0.0.1:${port}`, token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID, name: 'iPhone 17 Pro' });
|
||||
} finally {
|
||||
clearTimeout(responseTimer);
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not borrow metadata from another device', async () => {
|
||||
jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockResolvedValueOnce(
|
||||
new fetchModule.Response(
|
||||
JSON.stringify({ devices: [{ device: 'other-device', name: 'Other Simulator' }] })
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action transport policy', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
@@ -223,6 +326,7 @@ describe('host action connection', () => {
|
||||
let received: { id: number; action: string; params: Record<string, unknown> }[];
|
||||
let requests: (string | undefined)[];
|
||||
let apiBody: string;
|
||||
let apiStatus: number;
|
||||
let uploads: Map<string, Buffer>;
|
||||
let reply: (message: {
|
||||
id: number;
|
||||
@@ -232,6 +336,8 @@ describe('host action connection', () => {
|
||||
let tmpDir: string;
|
||||
let connection: HostActionConnection | undefined;
|
||||
let closeBeforeReady: boolean;
|
||||
let sendReady: boolean;
|
||||
let refuseUpgrade: boolean;
|
||||
|
||||
function serveSim(message: {
|
||||
id: number;
|
||||
@@ -266,13 +372,16 @@ describe('host action connection', () => {
|
||||
requests = [];
|
||||
uploads = new Map();
|
||||
apiBody = JSON.stringify({ device: UDID });
|
||||
apiStatus = 200;
|
||||
reply = serveSim;
|
||||
closeBeforeReady = false;
|
||||
sendReady = true;
|
||||
refuseUpgrade = false;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.writeHead(apiStatus, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
return;
|
||||
}
|
||||
@@ -282,6 +391,7 @@ describe('host action connection', () => {
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
verifyClient: () => !refuseUpgrade,
|
||||
handleProtocols: offered =>
|
||||
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
|
||||
});
|
||||
@@ -290,7 +400,9 @@ describe('host action connection', () => {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
if (sendReady) {
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
}
|
||||
ws.on('message', data => {
|
||||
const message = JSON.parse(data.toString());
|
||||
received.push(message);
|
||||
@@ -305,6 +417,51 @@ describe('host action connection', () => {
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
it('does not open a connection when already canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('canceled before setup');
|
||||
controller.abort(reason);
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl, token: TOKEN }, { signal: controller.signal })
|
||||
).rejects.toBe(reason);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancels an upgrade waiting for readiness and releases the abort listener', async () => {
|
||||
sendReady = false;
|
||||
const controller = new AbortController();
|
||||
const accepted = once(wss, 'connection');
|
||||
const pending = connectHostActionsAsync(
|
||||
{ apiUrl, token: TOKEN },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
const [socket] = await accepted;
|
||||
const closed = once(socket, 'close');
|
||||
const reason = new Error('canceled during setup');
|
||||
const rejected = expect(pending).rejects.toBe(reason);
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancels a pending action on an established connection', async () => {
|
||||
const controller = new AbortController();
|
||||
connection = await connectHostActionsAsync(
|
||||
{ apiUrl, token: TOKEN },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
reply = () => null;
|
||||
const receivedRequest = once([...wss.clients][0], 'message');
|
||||
const pending = connection.requestAsync('appearance.get');
|
||||
const reason = new Error('canceled during action');
|
||||
const rejected = expect(pending).rejects.toBe(reason);
|
||||
await receivedRequest;
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
expect(connection.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connection?.close();
|
||||
connection = undefined;
|
||||
@@ -324,6 +481,8 @@ describe('host action connection', () => {
|
||||
|
||||
it('sends several requests over one connection with the simulator udid', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
expect(hostActions.udid).toBe(UDID);
|
||||
expect(hostActions.signal.aborted).toBe(false);
|
||||
|
||||
const results = await Promise.all([
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
@@ -387,6 +546,69 @@ describe('host action connection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a timeout terminal, rejects every pending request and never sends another', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync(50);
|
||||
const requests = [
|
||||
hostActions.requestAsync('memoryWarning'),
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
];
|
||||
const results = await Promise.allSettled(requests);
|
||||
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
expect(results).toEqual([
|
||||
{ status: 'rejected', reason: hostActions.signal.reason },
|
||||
{ status: 'rejected', reason: hostActions.signal.reason },
|
||||
]);
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(hostActions.signal.reason);
|
||||
expect(received).toHaveLength(2);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect([...wss.clients].every(client => client.readyState !== 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts when the server closes an idle connection', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const aborted = once(hostActions.signal, 'abort');
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await aborted;
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(hostActions.signal.reason);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts and immediately rejects pending work when its owner closes it', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync();
|
||||
const request = hostActions.requestAsync('memoryWarning');
|
||||
const rejected = expect(request).rejects.toThrow('closed');
|
||||
hostActions.close();
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('makes a transport write failure terminal without retrying the request', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const failure = new Error('socket write failed');
|
||||
const send = jest
|
||||
.spyOn(WebSocket.prototype, 'send')
|
||||
.mockImplementation((_data, callback: unknown) => {
|
||||
if (typeof callback === 'function') {
|
||||
callback(failure);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(failure);
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(failure);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(received).toEqual([]);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a path on the API URL and drops its query', async () => {
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
|
||||
@@ -401,6 +623,29 @@ describe('host action connection', () => {
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('explains an unavailable preview server instead of a raw HTTP 404', async () => {
|
||||
apiStatus = 404;
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'The simulator preview server is unavailable. The session may have ended or expired.'
|
||||
);
|
||||
expect(requests).toEqual(['/api']);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('cleans up a WebSocket upgrade refused after the API accepted its token', async () => {
|
||||
refuseUpgrade = true;
|
||||
const upgraded = once(server, 'upgrade');
|
||||
const rejected = expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'refused the connection (HTTP 401)'
|
||||
);
|
||||
const [, socket] = await upgraded;
|
||||
const closed = once(socket, 'close');
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(wss.clients.size).toBe(0);
|
||||
expect(requests).toEqual(['/api']);
|
||||
});
|
||||
|
||||
it('does not send the session token to an API redirect target', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
@@ -423,11 +668,30 @@ describe('host action connection', () => {
|
||||
closeBeforeReady = true;
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
/closed the connection during setup.*token.*eas simulator:get/
|
||||
'closed the connection during setup'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
it('terminates its owned socket when setup never becomes ready', async () => {
|
||||
sendReady = false;
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
const connected = once(wss, 'connection');
|
||||
const rejected = expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'did not accept the connection within 30 seconds'
|
||||
);
|
||||
const [client] = await connected;
|
||||
const closed = once(client, 'close');
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(wss.clients.size).toBe(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'closes a refused HTTP %i upgrade without waiting for its body',
|
||||
async status => {
|
||||
let rejectedSocket: Duplex | undefined;
|
||||
@@ -446,7 +710,9 @@ describe('host action connection', () => {
|
||||
|
||||
try {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
`refused the connection (HTTP ${status})`
|
||||
status === 404
|
||||
? 'The simulator preview server is unavailable. The session may have ended or expired.'
|
||||
: `refused the connection (HTTP ${status})`
|
||||
);
|
||||
await Promise.race([
|
||||
closed,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Parser } from '@oclif/core';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Simulator from '../../commands/simulator';
|
||||
import { prepareControlConfigAsync } from '../controlConfig';
|
||||
import { resolveStartConfigAsync } from '../startConfig';
|
||||
|
||||
describe('simulator start configuration', () => {
|
||||
let folder: string;
|
||||
let file: string;
|
||||
beforeEach(async () => {
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-start-config-'));
|
||||
file = path.join(folder, 'simulator.json');
|
||||
});
|
||||
afterEach(async () => fs.remove(folder));
|
||||
|
||||
async function resolve(
|
||||
start: object,
|
||||
argv: string[] = [],
|
||||
actions?: object[]
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof resolveStartConfigAsync>> & {
|
||||
parsed: Awaited<ReturnType<typeof Parser.parse>>;
|
||||
}
|
||||
> {
|
||||
await fs.writeJson(file, { version: 1, start, ...(actions ? { actions } : {}) });
|
||||
const resolved = await resolveStartConfigAsync(['--import', file, ...argv], Simulator.flags);
|
||||
return { ...resolved, parsed: await Parser.parse(resolved.argv, { flags: Simulator.flags }) };
|
||||
}
|
||||
|
||||
it('uses saved values rather than command defaults and preserves explicit CLI overrides', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{
|
||||
platform: 'ios',
|
||||
type: 'web-preview-only',
|
||||
force: false,
|
||||
device: 'Saved phone',
|
||||
tag: ['saved'],
|
||||
'public-link': true,
|
||||
},
|
||||
['--device', 'CLI phone', '--tag', 'one', '--tag', 'two', '--json']
|
||||
);
|
||||
expect(parsed.flags).toMatchObject({
|
||||
platform: 'ios',
|
||||
type: 'web-preview-only',
|
||||
force: false,
|
||||
device: 'CLI phone',
|
||||
tag: ['one', 'two'],
|
||||
'public-link': true,
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets explicit CLI defaults override different saved values', async () => {
|
||||
const { parsed } = await resolve({ type: 'web-preview-only', force: false }, [
|
||||
'--type',
|
||||
'agent-device',
|
||||
'--force',
|
||||
]);
|
||||
expect(parsed.flags.type).toBe('agent-device');
|
||||
expect(parsed.flags.force).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the CLI disable saved public links and network capture with its saved fields', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{ 'public-link': true, 'network-capture': true, 'network-capture-field': ['header'] },
|
||||
['--no-public-link', '--no-network-capture']
|
||||
);
|
||||
expect(parsed.flags['public-link']).toBe(false);
|
||||
expect(parsed.flags['network-capture']).toBe(false);
|
||||
expect(parsed.flags['network-capture-field']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates dependencies after merging and replaces array flags instead of appending', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{ 'network-capture': true, 'network-capture-field': ['header'] },
|
||||
['--network-capture-field', 'request-body']
|
||||
);
|
||||
expect(parsed.flags['network-capture-field']).toEqual(['request-body']);
|
||||
});
|
||||
|
||||
it('lets an explicit app source replace a saved exclusive source and its Expo Go SDK', async () => {
|
||||
const expoGo = await resolve({ 'build-id': 'saved-build' }, ['--expo-go']);
|
||||
expect(expoGo.parsed.flags['expo-go']).toBe(true);
|
||||
expect(expoGo.parsed.flags['build-id']).toBeUndefined();
|
||||
const build = await resolve({ 'expo-go': true, 'sdk-version': '55.0.0' }, [
|
||||
'--build-id',
|
||||
'cli-build',
|
||||
]);
|
||||
expect(build.parsed.flags['build-id']).toBe('cli-build');
|
||||
expect(build.parsed.flags['expo-go']).toBeUndefined();
|
||||
expect(build.parsed.flags['sdk-version']).toBeUndefined();
|
||||
await expect(resolve({}, ['--build-id', 'cli-build', '--expo-go'])).rejects.toThrow(
|
||||
'cannot also be provided'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty import path instead of silently creating a default session', async () => {
|
||||
await expect(resolveStartConfigAsync(['--import='], Simulator.flags)).rejects.toThrow(
|
||||
'non-empty configuration file path'
|
||||
);
|
||||
});
|
||||
|
||||
it('honors short flags and negated CLI booleans', async () => {
|
||||
const { parsed } = await resolve({ platform: 'android', force: true }, [
|
||||
'-p',
|
||||
'ios',
|
||||
'--no-force',
|
||||
]);
|
||||
expect(parsed.flags.platform).toBe('ios');
|
||||
expect(parsed.flags.force).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: { unknown: 'x' }, message: 'Unknown or invocation-only' },
|
||||
{ start: { json: true }, message: 'invocation-only' },
|
||||
{ start: { 'non-interactive': true }, message: 'invocation-only' },
|
||||
{ start: { import: 'nested.json' }, message: 'invocation-only' },
|
||||
{ start: { 'expo-go': 'yes' }, message: 'must be a boolean' },
|
||||
{ start: { device: ['one', 'two'] }, message: 'multiple values' },
|
||||
{ start: { device: { name: 'phone' } }, message: 'string or number' },
|
||||
{ start: { platform: 'windows' }, message: 'ios' },
|
||||
{ start: { 'max-duration-minutes': -1 }, message: 'greater than or equal to 0' },
|
||||
{ start: { 'build-id': 'abc', 'expo-go': true }, message: 'cannot also be provided' },
|
||||
])('rejects invalid start values: $start', async ({ start, message }) => {
|
||||
await expect(resolve(start)).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it('rejects a missing local input before creating a session', async () => {
|
||||
await expect(
|
||||
resolve({ platform: 'ios' }, [], [{ action: 'app.install', file: './missing.ipa' }])
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
});
|
||||
|
||||
it('rejects invalid output parents and device overrides during local preflight', async () => {
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'screenshot.capture', output: './missing/shot.png' }])
|
||||
).rejects.toThrow('Config step 1');
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'appearance.get', params: { udid: 'other-device' } }])
|
||||
).rejects.toThrow('remove params.udid');
|
||||
});
|
||||
|
||||
it.each(['./missing/', './file.txt/'])(
|
||||
'rejects an output directory that does not exist: %s',
|
||||
async output => {
|
||||
await fs.writeFile(path.join(folder, 'file.txt'), 'existing file');
|
||||
await expect(resolve({}, [], [{ action: 'screenshot.capture', output }])).rejects.toThrow(
|
||||
'must be an existing directory'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['./screenshots/', './screen.png'])(
|
||||
'accepts an existing output directory or a new file: %s',
|
||||
async output => {
|
||||
await fs.mkdir(path.join(folder, 'screenshots'));
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'screenshot.capture', output }])
|
||||
).resolves.toBeDefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('allows the same file in control import and resolves files relative to it', async () => {
|
||||
await fs.writeFile(path.join(folder, 'input.txt'), 'contents');
|
||||
const { config } = await resolve(
|
||||
{ platform: 'ios' },
|
||||
[],
|
||||
[{ action: 'upload', file: './input.txt' }]
|
||||
);
|
||||
const prepared = await prepareControlConfigAsync(
|
||||
{
|
||||
upload: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['udid', 'path'],
|
||||
upload: 'path',
|
||||
},
|
||||
},
|
||||
file,
|
||||
'device'
|
||||
);
|
||||
expect(prepared.actions[0].plan.localFile?.path).toBe(path.join(folder, 'input.txt'));
|
||||
expect(config?.actions?.[0].params).toEqual({});
|
||||
});
|
||||
|
||||
it('reports an unreadable file before trying to create a session', async () => {
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
`Could not read simulator configuration ${file}`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves no-import argv and reports malformed or unknown config fields', async () => {
|
||||
expect(await resolveStartConfigAsync(['--platform', 'ios'], Simulator.flags)).toEqual({
|
||||
argv: ['--platform', 'ios'],
|
||||
});
|
||||
await fs.writeJson(file, { version: 2, start: {} });
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
'Invalid simulator configuration'
|
||||
);
|
||||
await fs.writeJson(file, { version: 1, typo: {} });
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
'Invalid simulator configuration'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { readSimulatorSessionConfigAsync } from '../sessionConfig';
|
||||
import { applyStartConfigAsync } from '../startSetup';
|
||||
|
||||
// Real HTTP/WebSocket transport and file IO verify the shared runner wiring, not just mocks.
|
||||
describe(applyStartConfigAsync, () => {
|
||||
let server: http.Server;
|
||||
let sockets: WebSocketServer;
|
||||
let folder: string;
|
||||
let apiUrl: string;
|
||||
let actions: { action: string; params: Record<string, unknown> }[];
|
||||
const catalog = {
|
||||
set: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, value: { type: 'string' } },
|
||||
required: ['udid', 'value'],
|
||||
},
|
||||
screenshot: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' } },
|
||||
required: ['udid'],
|
||||
output: 'base64',
|
||||
},
|
||||
};
|
||||
beforeEach(async () => {
|
||||
actions = [];
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'start-setup-'));
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.url !== '/api' || req.headers.authorization !== 'Bearer token') {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ device: 'owned-device' }));
|
||||
});
|
||||
sockets = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: protocols =>
|
||||
protocols.has('serve-sim.token.token') ? 'serve-sim.token.token' : false,
|
||||
});
|
||||
sockets.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', bytes => {
|
||||
const message = JSON.parse(bytes.toString());
|
||||
actions.push(message);
|
||||
const stdout =
|
||||
message.action === 'actions.describe'
|
||||
? JSON.stringify(catalog)
|
||||
: message.action === 'screenshot'
|
||||
? Buffer.from('image bytes').toString('base64')
|
||||
: 'done';
|
||||
socket.send(JSON.stringify({ id: message.id, stdout, stderr: '', exitCode: 0 }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets.clients) {
|
||||
socket.terminate();
|
||||
}
|
||||
sockets.close();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
await fs.remove(folder);
|
||||
});
|
||||
|
||||
it('runs the captured config after the file changes, writes outputs relative to it and closes transport', async () => {
|
||||
const file = path.join(folder, 'session.json');
|
||||
await fs.writeJson(file, {
|
||||
version: 1,
|
||||
start: { platform: 'ios' },
|
||||
actions: [
|
||||
{ action: 'set', params: { value: 'dark' } },
|
||||
{ action: 'screenshot', output: './screen.png' },
|
||||
],
|
||||
});
|
||||
const config = await readSimulatorSessionConfigAsync(file);
|
||||
await fs.writeJson(file, { version: 999 });
|
||||
const closed = new Promise<void>(resolve =>
|
||||
sockets.once('connection', socket => socket.once('close', resolve))
|
||||
);
|
||||
const progress = jest.fn();
|
||||
const results = await applyStartConfigAsync({ apiUrl, token: 'token' }, config, {
|
||||
signal: new AbortController().signal,
|
||||
onProgress: progress,
|
||||
});
|
||||
await closed;
|
||||
expect(results.map(result => result.action)).toEqual(['set', 'screenshot']);
|
||||
expect(await fs.readFile(path.join(folder, 'screen.png'), 'utf8')).toBe('image bytes');
|
||||
expect(actions.every(action => action.params.udid === 'owned-device')).toBe(true);
|
||||
expect(progress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ step: 2, total: 2, status: 'completed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('validates all actions before sending the first one and closes on validation failure', async () => {
|
||||
const file = path.join(folder, 'session.json');
|
||||
await fs.writeJson(file, {
|
||||
version: 1,
|
||||
actions: [{ action: 'set', params: { value: 'dark' } }, { action: 'unknown' }],
|
||||
});
|
||||
const config = await readSimulatorSessionConfigAsync(file);
|
||||
const closed = new Promise<void>(resolve =>
|
||||
sockets.once('connection', socket => socket.once('close', resolve))
|
||||
);
|
||||
await expect(
|
||||
applyStartConfigAsync({ apiUrl, token: 'token' }, config, {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Unknown simulator action');
|
||||
await closed;
|
||||
expect(actions.map(action => action.action)).toEqual(['actions.describe']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Other topic names that run `eas simulator:*` commands, for example `eas sim:start` or
|
||||
* `eas emulators:list`. Help and the README list the `sim` aliases. The other aliases are
|
||||
* hidden, so that help lists each command at most twice.
|
||||
*/
|
||||
export const SIMULATOR_TOPIC_ALIASES = ['sim'];
|
||||
export const SIMULATOR_HIDDEN_TOPIC_ALIASES = ['simulators', 'emulator', 'emulators'];
|
||||
|
||||
export function getSimulatorCommandAliases(name: string): string[] {
|
||||
return SIMULATOR_TOPIC_ALIASES.map(topic => `${topic}:${name}`);
|
||||
}
|
||||
|
||||
export function getSimulatorCommandHiddenAliases(name: string): string[] {
|
||||
return SIMULATOR_HIDDEN_TOPIC_ALIASES.map(topic => `${topic}:${name}`);
|
||||
}
|
||||
|
||||
export function isSimulatorCommandId(id: string | undefined, name: string): boolean {
|
||||
return (
|
||||
id === `simulator:${name}` ||
|
||||
(id !== undefined &&
|
||||
[...getSimulatorCommandAliases(name), ...getSimulatorCommandHiddenAliases(name)].includes(id))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from './hostActions';
|
||||
|
||||
export type ControlAction = {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
outputPath?: string;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export type ControlActionResult = { output: string; file: string | null };
|
||||
|
||||
export type PreparedControlAction = Readonly<{
|
||||
action: string;
|
||||
entry: HostActionCatalogEntry;
|
||||
params: Readonly<Record<string, unknown>>;
|
||||
outputPath?: string;
|
||||
localFile?: Readonly<{ param: string; path: string }>;
|
||||
}>;
|
||||
|
||||
export function resolveControlActionPath(value: string, baseDir?: string): string {
|
||||
if (!baseDir) {
|
||||
return value;
|
||||
}
|
||||
const resolved = path.resolve(baseDir, value);
|
||||
return value.endsWith('/') || value.endsWith(path.sep) ? `${resolved}${path.sep}` : resolved;
|
||||
}
|
||||
|
||||
export async function prepareControlActionAsync(
|
||||
catalog: HostActionCatalog,
|
||||
action: ControlAction,
|
||||
{
|
||||
localFileMode = 'auto',
|
||||
baseDir,
|
||||
}: { localFileMode?: 'auto' | 'explicit'; baseDir?: string } = {}
|
||||
): Promise<PreparedControlAction> {
|
||||
const entry = Object.hasOwn(catalog, action.action) ? catalog[action.action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action.action}". Run \`eas simulator:control --list\` to see the actions.`
|
||||
);
|
||||
}
|
||||
const params = { ...action.params };
|
||||
const outputPath = action.outputPath
|
||||
? resolveControlActionPath(action.outputPath, baseDir)
|
||||
: action.outputPath;
|
||||
await assertHostActionOutputAsync(action.action, entry, outputPath);
|
||||
|
||||
let localFile: PreparedControlAction['localFile'];
|
||||
const param = entry.upload;
|
||||
const applies = Object.entries(entry.uploadWhen ?? {}).every(
|
||||
([name, expected]) => params[name] === expected
|
||||
);
|
||||
if (action.file !== undefined) {
|
||||
if (!param || !applies) {
|
||||
throw new Error(`${action.action} does not accept a local file with these parameters.`);
|
||||
}
|
||||
if (Object.hasOwn(params, param)) {
|
||||
throw new Error(`${param} and file cannot both be supplied for ${action.action}.`);
|
||||
}
|
||||
localFile = { param, path: resolveControlActionPath(action.file, baseDir) };
|
||||
await assertLocalFileAsync(localFile.path);
|
||||
} else if (localFileMode === 'auto' && param && applies && typeof params[param] === 'string') {
|
||||
const value = params[param];
|
||||
if (value.startsWith('host:')) {
|
||||
if (entry.uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
} else {
|
||||
const localPath = resolveControlActionPath(value, baseDir);
|
||||
const stat = await fs.stat(localPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
await assertLocalFileAsync(localPath, stat);
|
||||
localFile = { param, path: localPath };
|
||||
}
|
||||
}
|
||||
return {
|
||||
action: action.action,
|
||||
entry,
|
||||
params,
|
||||
outputPath,
|
||||
...(localFile ? { localFile } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertLocalFileAsync(localPath: string, stats?: fs.Stats): Promise<void> {
|
||||
const stat = stats ?? (await fs.stat(localPath).catch(() => null));
|
||||
if (!stat) {
|
||||
throw new Error(`${localPath} does not exist on this computer.`);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${localPath} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${localPath} is not a regular file.`);
|
||||
}
|
||||
if (stat.size === 0) {
|
||||
throw new Error(`${localPath} is empty. Pass a file with content.`);
|
||||
}
|
||||
await fs.access(localPath, fs.constants.R_OK);
|
||||
}
|
||||
|
||||
export async function executeControlActionAsync(
|
||||
connection: HostActionConnection,
|
||||
plan: PreparedControlAction,
|
||||
{ validateParams }: { validateParams?: (params: Record<string, unknown>) => void } = {}
|
||||
): Promise<ControlActionResult> {
|
||||
connection.signal.throwIfAborted();
|
||||
const params: Record<string, unknown> = { ...plan.params };
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (plan.localFile) {
|
||||
const upload = await uploadFileAsync(connection, plan.localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[plan.localFile.param] = plan.entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
}
|
||||
connection.signal.throwIfAborted();
|
||||
validateParams?.(params);
|
||||
const result = await connection.requestAsync(plan.action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${plan.action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
const file = await saveHostActionOutputAsync(
|
||||
connection,
|
||||
plan.entry,
|
||||
result.stdout,
|
||||
plan.outputPath
|
||||
);
|
||||
return { output: file ? '' : result.stdout.trim(), file };
|
||||
} finally {
|
||||
if (uploadId && (!plan.entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseControlActionOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Ajv2020, { AnySchema } from 'ajv/dist/2020';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
ControlActionResult,
|
||||
PreparedControlAction,
|
||||
assertLocalFileAsync,
|
||||
executeControlActionAsync,
|
||||
parseControlActionOutput,
|
||||
prepareControlActionAsync,
|
||||
resolveControlActionPath,
|
||||
} from './controlActions';
|
||||
import { HostActionCatalog, HostActionConnection, createHostActionUploadId } from './hostActions';
|
||||
import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig';
|
||||
|
||||
type ConfigAction = {
|
||||
readonly plan: PreparedControlAction;
|
||||
readonly validateParams: (params: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type PreparedControlConfig = {
|
||||
readonly path: string;
|
||||
readonly udid: string;
|
||||
readonly actions: readonly ConfigAction[];
|
||||
};
|
||||
|
||||
export type ControlConfigResult = ControlActionResult & { action: string };
|
||||
|
||||
export type ControlConfigProgress = {
|
||||
step: number;
|
||||
total: number;
|
||||
action: string;
|
||||
} & ({ status: 'running' } | { status: 'completed'; result: ControlConfigResult });
|
||||
|
||||
export class ControlConfigExecutionError extends Error {
|
||||
constructor(
|
||||
readonly step: number,
|
||||
readonly action: string,
|
||||
readonly completed: readonly ControlConfigResult[],
|
||||
cause: unknown
|
||||
) {
|
||||
super(
|
||||
`Config step ${step} (${action}) failed after ${completed.length} completed actions: ${errorMessage(cause)}`,
|
||||
{ cause }
|
||||
);
|
||||
this.name = 'ControlConfigExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareControlConfigAsync(
|
||||
catalog: HostActionCatalog,
|
||||
filePath: string,
|
||||
udid: string,
|
||||
input?: SimulatorSessionConfig
|
||||
): Promise<PreparedControlConfig> {
|
||||
const config = input ?? (await readSimulatorSessionConfigAsync(filePath));
|
||||
const absolutePath = config.path;
|
||||
if (!config.actions?.length) {
|
||||
throw new Error(
|
||||
`Invalid simulator configuration ${absolutePath}: actions must contain at least one action.`
|
||||
);
|
||||
}
|
||||
|
||||
const ajv = addFormats(new Ajv2020({ strict: false, allErrors: true }));
|
||||
const actions: ConfigAction[] = [];
|
||||
for (const [index, action] of config.actions.entries()) {
|
||||
try {
|
||||
if (Object.hasOwn(action.params, 'udid')) {
|
||||
throw new Error('The simulator is selected by the session; remove params.udid.');
|
||||
}
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{ ...action, outputPath: action.output },
|
||||
{ localFileMode: 'explicit', baseDir: path.dirname(absolutePath) }
|
||||
);
|
||||
if (plan.entry.output) {
|
||||
if (!plan.outputPath) {
|
||||
throw new Error('Set output to a file or directory for this action.');
|
||||
}
|
||||
await assertWritableOutputAsync(plan.outputPath);
|
||||
}
|
||||
assertUnambiguousFileSource(ajv, plan, udid);
|
||||
const validate = ajv.compile(plan.entry as AnySchema);
|
||||
const validateParams = (params: Record<string, unknown>): void => {
|
||||
if (!validate({ ...params, udid })) {
|
||||
throw new Error(ajv.errorsText(validate.errors, { dataVar: 'params' }));
|
||||
}
|
||||
};
|
||||
const params = { ...plan.params };
|
||||
if (plan.localFile) {
|
||||
const uploadId = createHostActionUploadId(plan.localFile.path);
|
||||
params[plan.localFile.param] =
|
||||
plan.entry.uploadAs === 'id' ? uploadId : `/tmp/serve-sim-uploads/${uploadId}`;
|
||||
}
|
||||
validateParams(params);
|
||||
actions.push({ plan, validateParams });
|
||||
} catch (error) {
|
||||
throw new Error(`Config step ${index + 1} (${action.action}): ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { path: absolutePath, udid, actions };
|
||||
}
|
||||
|
||||
/** Check local inputs before requesting a paid session; runtime validation still uses the live catalog. */
|
||||
export async function preflightControlConfigFilesAsync(
|
||||
config: SimulatorSessionConfig
|
||||
): Promise<void> {
|
||||
for (const [index, action] of (config.actions ?? []).entries()) {
|
||||
try {
|
||||
if (Object.hasOwn(action.params, 'udid')) {
|
||||
throw new Error('The simulator is selected by the session; remove params.udid.');
|
||||
}
|
||||
if (action.file) {
|
||||
await assertLocalFileAsync(path.resolve(path.dirname(config.path), action.file));
|
||||
}
|
||||
if (action.output) {
|
||||
await assertWritableOutputAsync(
|
||||
resolveControlActionPath(action.output, path.dirname(config.path))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Config step ${index + 1} (${action.action}): ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertWritableOutputAsync(outputPath: string): Promise<void> {
|
||||
const stat = await fs.stat(outputPath).catch(() => null);
|
||||
if ((outputPath.endsWith('/') || outputPath.endsWith(path.sep)) && !stat?.isDirectory()) {
|
||||
throw new Error(`${outputPath} must be an existing directory.`);
|
||||
}
|
||||
if (stat && !stat.isFile() && !stat.isDirectory()) {
|
||||
throw new Error(`${outputPath} must be a regular file or directory.`);
|
||||
}
|
||||
await fs.access(
|
||||
stat ? outputPath : path.dirname(outputPath),
|
||||
fs.constants.W_OK | (stat?.isFile() ? 0 : fs.constants.X_OK)
|
||||
);
|
||||
}
|
||||
|
||||
function declaresParameter(schema: unknown, name: string): boolean {
|
||||
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
||||
return false;
|
||||
}
|
||||
const { properties, allOf } = schema as { properties?: object; allOf?: unknown[] };
|
||||
return (
|
||||
(properties !== undefined && Object.hasOwn(properties, name)) ||
|
||||
(Array.isArray(allOf) && allOf.some(child => declaresParameter(child, name)))
|
||||
);
|
||||
}
|
||||
|
||||
function assertUnambiguousFileSource(
|
||||
ajv: Ajv2020,
|
||||
plan: PreparedControlAction,
|
||||
udid: string
|
||||
): void {
|
||||
if (!plan.localFile) {
|
||||
return;
|
||||
}
|
||||
const { anyOf, oneOf, ...common } = plan.entry;
|
||||
for (const branch of anyOf ?? oneOf ?? []) {
|
||||
if (declaresParameter(branch, plan.localFile.param)) {
|
||||
continue;
|
||||
}
|
||||
const validate = ajv.compile({
|
||||
...common,
|
||||
allOf: [...(common.allOf ?? []), branch],
|
||||
} as AnySchema);
|
||||
if (validate({ ...plan.params, udid })) {
|
||||
throw new Error('file conflicts with another source in params. Supply only one file source.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeControlConfigAsync(
|
||||
connection: HostActionConnection,
|
||||
config: PreparedControlConfig,
|
||||
{ onProgress }: { onProgress?: (progress: ControlConfigProgress) => void } = {}
|
||||
): Promise<ControlConfigResult[]> {
|
||||
if (config.udid !== connection.udid) {
|
||||
throw new Error('The simulator changed after this configuration was checked. Import it again.');
|
||||
}
|
||||
const completed: ControlConfigResult[] = [];
|
||||
for (const [index, { plan, validateParams }] of config.actions.entries()) {
|
||||
const progress = { step: index + 1, total: config.actions.length, action: plan.action };
|
||||
onProgress?.({ ...progress, status: 'running' });
|
||||
let result: ControlActionResult;
|
||||
try {
|
||||
result = await executeControlActionAsync(connection, plan, { validateParams });
|
||||
} catch (error) {
|
||||
throw new ControlConfigExecutionError(index + 1, plan.action, completed, error);
|
||||
}
|
||||
const entry = { action: plan.action, ...result };
|
||||
completed.push(entry);
|
||||
onProgress?.({ ...progress, status: 'completed', result: entry });
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function serializeControlConfigResults(
|
||||
results: readonly ControlConfigResult[]
|
||||
): { action: string; output: unknown; file: string | null }[] {
|
||||
return results.map(result => ({ ...result, output: parseControlActionOutput(result.output) }));
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { fork } from 'node:child_process';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { createConnection } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import lockfile from 'proper-lockfile';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CONTROL_BOOTSTRAP_TIMEOUT_MS,
|
||||
CONTROL_CONNECTION_VERSION,
|
||||
CONTROL_LEASE_WAIT_TIMEOUT_MS,
|
||||
CONTROL_REQUEST_TIMEOUT_MS,
|
||||
CONTROL_SETUP_TIMEOUT_MS,
|
||||
ConnectionInfoSchema,
|
||||
ConnectionOperation,
|
||||
ControlConnectionChannel,
|
||||
ControlConnectionInfo,
|
||||
} from './controlConnectionProtocol';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogSchema,
|
||||
HostActionConnection,
|
||||
HostActionReplySchema,
|
||||
} from './hostActions';
|
||||
import { PreviewApiAccess } from './utils';
|
||||
import { getExpoApiBaseUrl } from '../api';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getStateJsonPath } from '../utils/paths';
|
||||
|
||||
export type ControlConnectionIdentity = {
|
||||
root: string;
|
||||
socketPath: string;
|
||||
credentialHash: string;
|
||||
scope: string;
|
||||
sessionStatePath?: string;
|
||||
};
|
||||
export type ControlConnectionBootstrap = {
|
||||
access: PreviewApiAccess;
|
||||
session: Omit<ControlConnectionInfo['session'], 'device'>;
|
||||
};
|
||||
export type BorrowedControlConnection = {
|
||||
connection: HostActionConnection;
|
||||
catalog: HostActionCatalog;
|
||||
info: ControlConnectionInfo;
|
||||
};
|
||||
|
||||
export function assertControlKeepAliveSupported(): void {
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error(
|
||||
'Control keep-alive is currently supported on macOS and Linux. Regular control commands still work on Windows.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createControlConnectionIdentityAsync(
|
||||
sessionId: string,
|
||||
projectDir: string,
|
||||
authentication: LoggedInAuthenticationInfo,
|
||||
root?: string
|
||||
): Promise<ControlConnectionIdentity> {
|
||||
const credential = authentication.accessToken ?? authentication.sessionSecret;
|
||||
if (!credential) {
|
||||
throw new Error('Sign in before using a simulator control connection.');
|
||||
}
|
||||
const scope = JSON.stringify([
|
||||
CONTROL_CONNECTION_VERSION,
|
||||
easCliVersion,
|
||||
path.resolve(__dirname),
|
||||
getExpoApiBaseUrl(),
|
||||
await fs.realpath(projectDir),
|
||||
sessionId,
|
||||
]);
|
||||
const credentialHash = createHmac('sha256', credential)
|
||||
.update('eas-control-connection:' + scope)
|
||||
.digest('hex');
|
||||
const name = credentialHash.slice(0, 32);
|
||||
const runtimeName = createHmac('sha256', credential)
|
||||
.update('eas-control-runtime:' + JSON.stringify([getExpoApiBaseUrl(), process.getuid?.()]))
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
root ??= path.join('/tmp', 'ectl-' + runtimeName);
|
||||
return {
|
||||
root,
|
||||
socketPath: path.join(root, name + '.sock'),
|
||||
credentialHash,
|
||||
scope,
|
||||
...(authentication.sessionSecret ? { sessionStatePath: getStateJsonPath() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertControlConnectionCredentialAsync(
|
||||
identity: ControlConnectionIdentity
|
||||
): Promise<void> {
|
||||
if (!identity.sessionStatePath) {
|
||||
return;
|
||||
}
|
||||
const stored = await fs.readJson(identity.sessionStatePath).catch(() => null);
|
||||
const credential: unknown = stored?.auth?.sessionSecret;
|
||||
if (typeof credential !== 'string') {
|
||||
throw new Error('The EAS login used by this connection has ended.');
|
||||
}
|
||||
const credentialHash = createHmac('sha256', credential)
|
||||
.update('eas-control-connection:' + identity.scope)
|
||||
.digest('hex');
|
||||
if (credentialHash !== identity.credentialHash) {
|
||||
throw new Error('The EAS login used by this connection has changed.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureControlConnectionDirectoryAsync(root: string): Promise<void> {
|
||||
assertControlKeepAliveSupported();
|
||||
try {
|
||||
await fs.mkdir(root, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await assertControlConnectionDirectoryAsync(root);
|
||||
}
|
||||
|
||||
async function assertControlConnectionDirectoryAsync(root: string): Promise<void> {
|
||||
const stat = await fs.lstat(root);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== process.getuid?.() ||
|
||||
(stat.mode & 0o777) !== 0o700
|
||||
) {
|
||||
throw new Error(
|
||||
'The local control directory must be owned by this user with permissions 0700.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasControlSocketAsync(identity: ControlConnectionIdentity): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(identity.socketPath);
|
||||
await assertControlConnectionDirectoryAsync(identity.root);
|
||||
if (!stat.isSocket() || stat.uid !== process.getuid?.()) {
|
||||
throw new Error('The local control endpoint is not a private socket owned by this user.');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withControlConnectionLockAsync<T>(
|
||||
identity: ControlConnectionIdentity,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
await ensureControlConnectionDirectoryAsync(identity.root);
|
||||
const release = await lockfile.lock(identity.socketPath, {
|
||||
realpath: false,
|
||||
stale: 10_000,
|
||||
update: 2_000,
|
||||
retries: { retries: 1_200, minTimeout: 50, maxTimeout: 200 },
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectChannelAsync(
|
||||
identity: ControlConnectionIdentity,
|
||||
operation: ConnectionOperation
|
||||
): Promise<ControlConnectionChannel | null> {
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
}
|
||||
if (!(await hasControlSocketAsync(identity))) {
|
||||
return null;
|
||||
}
|
||||
const socket = createConnection(identity.socketPath);
|
||||
const channel = new ControlConnectionChannel(socket);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const error = new Error('Local control connection timed out.');
|
||||
channel.destroy(error);
|
||||
reject(error);
|
||||
}, CONTROL_SETUP_TIMEOUT_MS);
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', error => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
// Private filesystem ownership is the authority boundary; same-UID processes are trusted.
|
||||
await channel.writeAsync(operation);
|
||||
return channel;
|
||||
} catch (error) {
|
||||
channel.destroy();
|
||||
if (['ENOENT', 'ECONNREFUSED'].includes((error as NodeJS.ErrnoException).code ?? '')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const ReadySchema = z.object({ info: ConnectionInfoSchema, catalog: HostActionCatalogSchema });
|
||||
|
||||
async function readControlReplyAsync(
|
||||
channel: ControlConnectionChannel,
|
||||
timeoutMs?: number,
|
||||
timeoutMessage?: string
|
||||
): Promise<unknown> {
|
||||
const reply = await channel.readAsync(timeoutMs, timeoutMessage);
|
||||
const error = z.strictObject({ error: z.string() }).safeParse(reply);
|
||||
if (error.success) {
|
||||
throw new Error(error.data.error);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
export async function borrowControlConnectionAsync(
|
||||
identity: ControlConnectionIdentity
|
||||
): Promise<BorrowedControlConnection | null> {
|
||||
const channel = await connectChannelAsync(identity, { operation: 'lease' });
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { info, catalog } = ReadySchema.parse(
|
||||
await readControlReplyAsync(
|
||||
channel,
|
||||
CONTROL_LEASE_WAIT_TIMEOUT_MS,
|
||||
'The control connection is busy; no action was sent. Try again after the current command finishes.'
|
||||
)
|
||||
);
|
||||
const controller = new AbortController();
|
||||
channel.socket.once('close', () => {
|
||||
controller.abort(
|
||||
new Error('The local control connection closed. An action already sent may still have run.')
|
||||
);
|
||||
});
|
||||
let nextId = 1;
|
||||
return {
|
||||
info,
|
||||
catalog,
|
||||
connection: {
|
||||
signal: controller.signal,
|
||||
udid: info.session.device.udid,
|
||||
async requestAsync(action, params = {}) {
|
||||
controller.signal.throwIfAborted();
|
||||
if (action === 'actions.describe') {
|
||||
return { stdout: JSON.stringify(catalog), stderr: '', exitCode: 0 };
|
||||
}
|
||||
const id = nextId++;
|
||||
await channel.writeAsync({ id, action, params });
|
||||
const result = HostActionReplySchema.parse(
|
||||
await readControlReplyAsync(channel, CONTROL_REQUEST_TIMEOUT_MS)
|
||||
);
|
||||
if (result.id !== id) {
|
||||
channel.destroy();
|
||||
throw new Error('The local control reply did not match the request.');
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
close() {
|
||||
channel.destroy();
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
channel.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectControlConnectionAsync(
|
||||
identity: ControlConnectionIdentity,
|
||||
operation: ConnectionOperation = { operation: 'status' }
|
||||
): Promise<ControlConnectionInfo | null> {
|
||||
const channel = await connectChannelAsync(identity, operation);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const info = ConnectionInfoSchema.parse(
|
||||
await readControlReplyAsync(
|
||||
channel,
|
||||
operation.operation === 'close' ? CONTROL_LEASE_WAIT_TIMEOUT_MS : undefined,
|
||||
operation.operation === 'close'
|
||||
? 'Close could not be confirmed; no simulator action was sent.'
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
return info;
|
||||
} catch (error) {
|
||||
if (operation.operation === 'close' && channel.socket.destroyed) {
|
||||
throw new Error('Close could not be confirmed; no simulator action was sent.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
channel.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openControlConnectionAsync(
|
||||
identity: ControlConnectionIdentity,
|
||||
load: () => Promise<ControlConnectionBootstrap>,
|
||||
idleTimeoutMs?: number,
|
||||
workerPath = path.join(__dirname, 'controlConnectionWorker.js')
|
||||
): Promise<ControlConnectionInfo> {
|
||||
assertControlKeepAliveSupported();
|
||||
return await withControlConnectionLockAsync(identity, async () => {
|
||||
const existing = await inspectControlConnectionAsync(identity, {
|
||||
operation: 'status',
|
||||
...(idleTimeoutMs !== undefined ? { idleTimeoutMs } : {}),
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
if (await hasControlSocketAsync(identity)) {
|
||||
await fs.unlink(identity.socketPath);
|
||||
}
|
||||
const bootstrap = await load();
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USER',
|
||||
'LOGNAME',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'EXPO_STAGING',
|
||||
'EXPO_LOCAL',
|
||||
'https_proxy',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
]) {
|
||||
if (process.env[key] !== undefined) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
const child = fork(workerPath, [], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
execArgv: [],
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
});
|
||||
try {
|
||||
return await new Promise<ControlConnectionInfo>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error('The control helper did not become ready in time.'));
|
||||
}, CONTROL_BOOTSTRAP_TIMEOUT_MS);
|
||||
const finish = (result: Error | ControlConnectionInfo): void => {
|
||||
clearTimeout(timer);
|
||||
child.removeListener('error', onError);
|
||||
child.removeListener('exit', onExit);
|
||||
child.removeListener('message', onMessage);
|
||||
result instanceof Error ? reject(result) : resolve(result);
|
||||
};
|
||||
const onError = (): void => {
|
||||
finish(new Error('Could not start the local control helper.'));
|
||||
};
|
||||
const onExit = (): void => {
|
||||
finish(new Error('The local control helper exited during setup.'));
|
||||
};
|
||||
const onMessage = (message: unknown): void => {
|
||||
const error = z.object({ error: z.string() }).safeParse(message);
|
||||
if (error.success) {
|
||||
finish(new Error(error.data.error));
|
||||
} else {
|
||||
const ready = z
|
||||
.object({ ready: z.literal(true), info: ConnectionInfoSchema })
|
||||
.safeParse(message);
|
||||
if (ready.success) {
|
||||
finish(ready.data.info);
|
||||
}
|
||||
}
|
||||
};
|
||||
child.once('error', onError);
|
||||
child.once('exit', onExit);
|
||||
child.on('message', onMessage);
|
||||
child.send({
|
||||
identity,
|
||||
bootstrap,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (child.connected) {
|
||||
child.disconnect();
|
||||
}
|
||||
child.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Socket } from 'node:net';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DeviceRunSessionStatus } from '../graphql/generated';
|
||||
|
||||
export const CONTROL_CONNECTION_VERSION = 3;
|
||||
export const DEFAULT_CONTROL_IDLE_TIMEOUT_MS = 120_000;
|
||||
export const CONTROL_SETUP_TIMEOUT_MS = 10_000;
|
||||
export const CONTROL_BOOTSTRAP_TIMEOUT_MS = 240_000;
|
||||
export const CONTROL_REQUEST_TIMEOUT_MS = 145_000;
|
||||
export const CONTROL_LEASE_WAIT_TIMEOUT_MS = 30_000;
|
||||
// Preserve replies supported by the remote WebSocket's 100 MiB payload limit.
|
||||
export const MAX_CONTROL_FRAME_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export const ControlSessionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().nullable(),
|
||||
project: z.string(),
|
||||
status: z.enum(DeviceRunSessionStatus),
|
||||
device: z.object({
|
||||
udid: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
runtime: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export type ControlSession = z.infer<typeof ControlSessionSchema>;
|
||||
|
||||
export const ConnectionInfoSchema = z.object({
|
||||
version: z.literal(CONTROL_CONNECTION_VERSION),
|
||||
pid: z.number().int().positive(),
|
||||
session: ControlSessionSchema,
|
||||
idleTimeoutMs: z.number().int().positive(),
|
||||
idleDeadline: z.number().nullable(),
|
||||
busy: z.boolean(),
|
||||
});
|
||||
export type ControlConnectionInfo = z.infer<typeof ConnectionInfoSchema>;
|
||||
|
||||
export const ConnectionOperationSchema = z.strictObject({
|
||||
operation: z.enum(['lease', 'status', 'close']),
|
||||
idleTimeoutMs: z.number().int().min(1_000).max(86_400_000).optional(),
|
||||
});
|
||||
export type ConnectionOperation = z.infer<typeof ConnectionOperationSchema>;
|
||||
|
||||
// Pull one bounded JSON frame at a time. Closing a client discards queued input,
|
||||
// so disconnected callers cannot leave another action waiting to execute.
|
||||
export class ControlConnectionChannel {
|
||||
private readonly decoder = new StringDecoder('utf8');
|
||||
private fragments: string[] = [];
|
||||
private frameBytes = 0;
|
||||
private queued: unknown[] = [];
|
||||
private failure: Error | undefined;
|
||||
private reader: { resolve(value: unknown): void; reject(error: Error): void } | undefined;
|
||||
|
||||
constructor(readonly socket: Socket) {
|
||||
socket.on('data', chunk => {
|
||||
try {
|
||||
const text = this.decoder.write(chunk);
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const newline = text.indexOf('\n', start);
|
||||
const fragment = text.slice(start, newline === -1 ? text.length : newline);
|
||||
this.frameBytes += Buffer.byteLength(fragment);
|
||||
if (this.frameBytes > MAX_CONTROL_FRAME_BYTES) {
|
||||
throw new Error('Local control message is too large.');
|
||||
}
|
||||
this.fragments.push(fragment);
|
||||
if (newline === -1) {
|
||||
break;
|
||||
}
|
||||
const value: unknown = JSON.parse(this.fragments.join(''));
|
||||
this.fragments = [];
|
||||
this.frameBytes = 0;
|
||||
start = newline + 1;
|
||||
if (this.reader) {
|
||||
const reader = this.reader;
|
||||
this.reader = undefined;
|
||||
reader.resolve(value);
|
||||
} else {
|
||||
if (this.queued.length >= 8) {
|
||||
throw new Error('Too many pending local control messages.');
|
||||
}
|
||||
this.queued.push(value);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.destroy(new Error('Invalid or oversized local control message.'));
|
||||
}
|
||||
});
|
||||
socket.on('error', error => {
|
||||
this.fail(error);
|
||||
});
|
||||
socket.on('end', () => {
|
||||
this.destroy(new Error('Local control caller disconnected.'));
|
||||
});
|
||||
socket.on('close', () => {
|
||||
this.fail(
|
||||
new Error('Local control connection closed. An action already sent may still have run.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
this.failure ??= error;
|
||||
this.fragments = [];
|
||||
this.frameBytes = 0;
|
||||
this.queued = [];
|
||||
this.reader?.reject(this.failure);
|
||||
this.reader = undefined;
|
||||
}
|
||||
|
||||
destroy(error = new Error('Local control connection closed.')): void {
|
||||
this.fail(error);
|
||||
this.socket.destroy();
|
||||
}
|
||||
|
||||
async readAsync(
|
||||
timeoutMs = CONTROL_SETUP_TIMEOUT_MS,
|
||||
timeoutMessage = 'Local control connection timed out. An action already sent may still have run.'
|
||||
): Promise<unknown> {
|
||||
if (this.failure) {
|
||||
throw this.failure;
|
||||
}
|
||||
if (this.queued.length) {
|
||||
return this.queued.shift();
|
||||
}
|
||||
if (this.reader) {
|
||||
throw new Error('A local control reply is already pending.');
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.destroy(new Error(timeoutMessage));
|
||||
}, timeoutMs);
|
||||
this.reader = {
|
||||
resolve: value => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async writeAsync(value: unknown): Promise<void> {
|
||||
if (this.failure) {
|
||||
throw this.failure;
|
||||
}
|
||||
const text = JSON.stringify(value) + '\n';
|
||||
if (Buffer.byteLength(text) > MAX_CONTROL_FRAME_BYTES) {
|
||||
throw new Error('Local control message is too large.');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const error = new Error(
|
||||
'Local control write timed out. An action already sent may still have run.'
|
||||
);
|
||||
this.destroy(error);
|
||||
reject(error);
|
||||
}, CONTROL_SETUP_TIMEOUT_MS);
|
||||
this.socket.write(text, error => {
|
||||
clearTimeout(timer);
|
||||
error ? reject(error) : resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ControlConnectionIdentity,
|
||||
assertControlConnectionCredentialAsync,
|
||||
ensureControlConnectionDirectoryAsync,
|
||||
withControlConnectionLockAsync,
|
||||
} from './controlConnection';
|
||||
import {
|
||||
CONTROL_CONNECTION_VERSION,
|
||||
CONTROL_SETUP_TIMEOUT_MS,
|
||||
ConnectionOperationSchema,
|
||||
ControlConnectionChannel,
|
||||
ControlConnectionInfo,
|
||||
ControlSessionSchema,
|
||||
DEFAULT_CONTROL_IDLE_TIMEOUT_MS,
|
||||
} from './controlConnectionProtocol';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionConnection,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
} from './hostActions';
|
||||
|
||||
const Request = z.strictObject({
|
||||
id: z.number().int().positive(),
|
||||
action: z.string().min(1).max(200),
|
||||
params: z.record(z.string(), z.json()),
|
||||
});
|
||||
|
||||
export async function serveControlConnectionAsync(
|
||||
identity: ControlConnectionIdentity,
|
||||
session: ControlConnectionInfo['session'],
|
||||
remote: HostActionConnection,
|
||||
catalog: HostActionCatalog,
|
||||
initialIdleTimeoutMs = DEFAULT_CONTROL_IDLE_TIMEOUT_MS
|
||||
): Promise<{ closeAsync(): Promise<void>; info: ControlConnectionInfo }> {
|
||||
await ensureControlConnectionDirectoryAsync(identity.root);
|
||||
let idleTimeoutMs = initialIdleTimeoutMs;
|
||||
let idleDeadline: number | null = null;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let authTimer: NodeJS.Timeout | undefined;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
let closeStarted: Promise<void> | undefined;
|
||||
let leaseTail = Promise.resolve();
|
||||
let leases = 0;
|
||||
let active: ControlConnectionChannel | undefined;
|
||||
const channels = new Set<ControlConnectionChannel>();
|
||||
const info = (): ControlConnectionInfo => ({
|
||||
version: CONTROL_CONNECTION_VERSION,
|
||||
pid: process.pid,
|
||||
session,
|
||||
idleTimeoutMs,
|
||||
idleDeadline,
|
||||
busy: Boolean(active),
|
||||
});
|
||||
|
||||
const resetIdle = (): void => {
|
||||
clearTimeout(timer);
|
||||
idleDeadline = null;
|
||||
if (leases || closePromise) {
|
||||
return;
|
||||
}
|
||||
idleDeadline = Date.now() + idleTimeoutMs;
|
||||
timer = setTimeout(() => {
|
||||
void closeAsync();
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
|
||||
async function closeAsync(requester?: ControlConnectionChannel): Promise<void> {
|
||||
if (!closePromise) {
|
||||
clearTimeout(timer);
|
||||
for (const channel of channels) {
|
||||
if (channel !== active) {
|
||||
channel.destroy();
|
||||
}
|
||||
}
|
||||
let acknowledge!: () => void;
|
||||
closeStarted = new Promise<void>(resolve => {
|
||||
acknowledge = resolve;
|
||||
});
|
||||
closePromise = (async () => {
|
||||
clearInterval(authTimer);
|
||||
await withControlConnectionLockAsync(identity, async () => {
|
||||
acknowledge();
|
||||
await leaseTail;
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close(error => {
|
||||
error ? reject(error) : resolve();
|
||||
})
|
||||
);
|
||||
remote.close();
|
||||
});
|
||||
remote.signal.removeEventListener('abort', onRemoteClosed);
|
||||
})().catch(() => process.exit(1));
|
||||
}
|
||||
if (requester) {
|
||||
// Every close acknowledges under ownership, including repeated close calls.
|
||||
await closeStarted;
|
||||
await requester.writeAsync(info()).catch(() => {});
|
||||
requester.socket.destroySoon();
|
||||
}
|
||||
await closePromise;
|
||||
}
|
||||
|
||||
async function handleAsync(channel: ControlConnectionChannel): Promise<void> {
|
||||
const operation = ConnectionOperationSchema.parse(await channel.readAsync());
|
||||
await assertControlConnectionCredentialAsync(identity);
|
||||
if (channel.socket.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (closePromise && operation.operation !== 'close') {
|
||||
throw new Error(
|
||||
'The control connection is closing; no action was sent. Try again after it closes.'
|
||||
);
|
||||
}
|
||||
if (operation.idleTimeoutMs !== undefined) {
|
||||
idleTimeoutMs = operation.idleTimeoutMs;
|
||||
resetIdle();
|
||||
}
|
||||
if (operation.operation !== 'lease') {
|
||||
if (operation.operation === 'close') {
|
||||
// Keep this caller alive until shutdown acknowledges its request.
|
||||
channels.delete(channel);
|
||||
await closeAsync(channel);
|
||||
} else {
|
||||
await channel.writeAsync(info());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (leases >= 16) {
|
||||
throw new Error(
|
||||
'The control connection is busy; no action was sent. Try again after the current command finishes.'
|
||||
);
|
||||
}
|
||||
const previous = leaseTail;
|
||||
let release!: () => void;
|
||||
leaseTail = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
leases++;
|
||||
clearTimeout(timer);
|
||||
idleDeadline = null;
|
||||
let cleanupTimer: NodeJS.Timeout | undefined;
|
||||
const limitCleanup = (): void => {
|
||||
if (closePromise && !cleanupTimer) {
|
||||
cleanupTimer = setTimeout(() => {
|
||||
channel.destroy();
|
||||
}, CONTROL_SETUP_TIMEOUT_MS);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await previous;
|
||||
if (closePromise || channel.socket.destroyed) {
|
||||
return;
|
||||
}
|
||||
active = channel;
|
||||
await channel.writeAsync({ info: info(), catalog });
|
||||
for (;;) {
|
||||
const request = Request.parse(await channel.readAsync(30_000));
|
||||
await assertControlConnectionCredentialAsync(identity);
|
||||
if (channel.socket.destroyed) {
|
||||
return;
|
||||
}
|
||||
limitCleanup();
|
||||
if (closePromise && request.action !== 'upload.remove') {
|
||||
await channel.writeAsync({
|
||||
id: request.id,
|
||||
error: 'The control connection is closing; no action was sent.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let result: HostActionResult;
|
||||
try {
|
||||
result = await remote.requestAsync(request.action, request.params);
|
||||
} catch (error) {
|
||||
remote.signal.throwIfAborted();
|
||||
await channel.writeAsync({
|
||||
id: request.id,
|
||||
error: error instanceof Error ? error.message : 'Simulator action failed.',
|
||||
});
|
||||
continue;
|
||||
} finally {
|
||||
limitCleanup();
|
||||
}
|
||||
await channel.writeAsync({ id: request.id, ...result });
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(cleanupTimer);
|
||||
if (active === channel) {
|
||||
active = undefined;
|
||||
}
|
||||
leases--;
|
||||
release();
|
||||
resetIdle();
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(socket => {
|
||||
const channel = new ControlConnectionChannel(socket);
|
||||
channels.add(channel);
|
||||
socket.once('close', () => channels.delete(channel));
|
||||
void handleAsync(channel)
|
||||
.catch(async error => {
|
||||
if (!socket.destroyed) {
|
||||
await channel
|
||||
.writeAsync({
|
||||
error: error instanceof Error ? error.message : 'Local control request failed.',
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
socket.destroySoon();
|
||||
});
|
||||
});
|
||||
server.maxConnections = 32;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(identity.socketPath, () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const onRemoteClosed = (): void => {
|
||||
for (const channel of channels) {
|
||||
channel.destroy();
|
||||
}
|
||||
void closeAsync();
|
||||
};
|
||||
authTimer = setInterval(() => {
|
||||
void assertControlConnectionCredentialAsync(identity).catch(onRemoteClosed);
|
||||
}, 1_000);
|
||||
authTimer.unref();
|
||||
remote.signal.addEventListener('abort', onRemoteClosed, { once: true });
|
||||
if (remote.signal.aborted) {
|
||||
onRemoteClosed();
|
||||
}
|
||||
resetIdle();
|
||||
return { closeAsync, info: info() };
|
||||
}
|
||||
|
||||
async function runWorkerAsync(): Promise<void> {
|
||||
if (!process.send) {
|
||||
throw new Error('Control helpers must be started by EAS CLI.');
|
||||
}
|
||||
process.umask(0o077);
|
||||
// Until readiness is handed back, parent loss cancels all bootstrap work.
|
||||
const onParentGone = (): never => process.exit(1);
|
||||
process.once('disconnect', onParentGone);
|
||||
const input = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Control bootstrap timed out.'));
|
||||
}, 10_000);
|
||||
process.once('message', message => {
|
||||
clearTimeout(timer);
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
const parsed = z
|
||||
.object({
|
||||
identity: z.object({
|
||||
root: z.string(),
|
||||
socketPath: z.string(),
|
||||
credentialHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
scope: z.string(),
|
||||
sessionStatePath: z.string().optional(),
|
||||
}),
|
||||
bootstrap: z.object({
|
||||
access: z.object({ apiUrl: z.string(), token: z.string() }),
|
||||
session: ControlSessionSchema.omit({ device: true }),
|
||||
}),
|
||||
idleTimeoutMs: ConnectionOperationSchema.shape.idleTimeoutMs,
|
||||
})
|
||||
.parse(input);
|
||||
const { identity, bootstrap } = parsed;
|
||||
const remote = await connectHostActionsAsync(bootstrap.access);
|
||||
try {
|
||||
const catalog = await fetchHostActionCatalogAsync(remote);
|
||||
const device = await fetchHostActionDeviceAsync(bootstrap.access, remote.udid);
|
||||
const server = await serveControlConnectionAsync(
|
||||
identity,
|
||||
{ ...bootstrap.session, device },
|
||||
remote,
|
||||
catalog,
|
||||
parsed.idleTimeoutMs
|
||||
);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void server.closeAsync();
|
||||
});
|
||||
}
|
||||
process.removeListener('disconnect', onParentGone);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.send!({ ready: true, info: server.info }, (error: Error | null) => {
|
||||
error ? reject(error) : resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
remote.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void runWorkerAsync().catch(error => {
|
||||
if (process.connected && process.send) {
|
||||
process.send(
|
||||
{ error: error instanceof Error ? error.message : 'Local control setup failed.' },
|
||||
() => process.exit(1)
|
||||
);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ControlConnectionBootstrap } from './controlConnection';
|
||||
import { getPreviewApiAccess } from './utils';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery';
|
||||
|
||||
export async function loadControlSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
sessionId: string
|
||||
): Promise<ControlConnectionBootstrap> {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(
|
||||
`Simulator control on ${session.platform.toLowerCase()} is not supported yet. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
access,
|
||||
session: {
|
||||
id: session.id,
|
||||
name: session.name ?? null,
|
||||
project: `@${session.app.ownerAccount.name}/${session.app.slug}`,
|
||||
status: session.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export type HostActionResult = {
|
||||
};
|
||||
|
||||
export type HostActionConnection = {
|
||||
readonly signal: AbortSignal;
|
||||
readonly udid: string;
|
||||
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
|
||||
close(): void;
|
||||
};
|
||||
@@ -37,17 +39,18 @@ const CatalogEntry = SchemaNode.extend({
|
||||
output: z.enum(['path', 'base64']).optional(),
|
||||
});
|
||||
|
||||
const Catalog = z.record(z.string(), CatalogEntry);
|
||||
export const HostActionCatalogSchema = z.record(z.string(), CatalogEntry);
|
||||
|
||||
export type HostActionCatalog = z.infer<typeof Catalog>;
|
||||
export type HostActionCatalog = z.infer<typeof HostActionCatalogSchema>;
|
||||
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
|
||||
|
||||
export type HostActionDevice = { udid: string; name?: string; runtime?: string };
|
||||
|
||||
const PreviewConfig = z.object({ device: z.string().min(1) });
|
||||
|
||||
const ReplyId = z.object({ id: z.number() });
|
||||
|
||||
const SocketMessage = z.union([
|
||||
z.object({ ready: z.literal(true) }),
|
||||
export const HostActionReplySchema = z.union([
|
||||
z.object({ id: z.number(), error: z.string() }),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -56,9 +59,12 @@ const SocketMessage = z.union([
|
||||
exitCode: z.number(),
|
||||
}),
|
||||
]);
|
||||
const SocketMessage = z.union([z.object({ ready: z.literal(true) }), HostActionReplySchema]);
|
||||
|
||||
const HOST_ACTION_TIMEOUT_MS = 130_000;
|
||||
const PREVIEW_API_TIMEOUT_MS = 30_000;
|
||||
const PREVIEW_UNAVAILABLE_MESSAGE =
|
||||
'The simulator preview server is unavailable. The session may have ended or expired. Check it with `eas simulator:get`, or start a new session with `eas simulator:start`.';
|
||||
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
|
||||
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
|
||||
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
||||
@@ -82,16 +88,25 @@ function previewEndpoint(apiUrl: string, endpoint: string): URL {
|
||||
return new URL(endpoint, base);
|
||||
}
|
||||
|
||||
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
|
||||
async function fetchSimulatorUdidAsync(
|
||||
{ apiUrl, token }: PreviewApiAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
let text: string;
|
||||
try {
|
||||
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error instanceof RequestError && error.response.status === 404) {
|
||||
throw new Error(PREVIEW_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
|
||||
throw new Error(
|
||||
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
@@ -117,6 +132,31 @@ async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Pro
|
||||
return config.data.device;
|
||||
}
|
||||
|
||||
export async function fetchHostActionDeviceAsync(
|
||||
{ apiUrl, token }: PreviewApiAccess,
|
||||
udid: string
|
||||
): Promise<HostActionDevice> {
|
||||
const url = previewEndpoint(apiUrl, 'grid/api/catalog').toString();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
const catalog = z
|
||||
.object({
|
||||
devices: z.array(
|
||||
z.object({ device: z.string(), name: z.string(), runtime: z.string().optional() })
|
||||
),
|
||||
})
|
||||
.parse(await response.json());
|
||||
const device = catalog.devices.find(device => device.device === udid);
|
||||
return device ? { udid, name: device.name, runtime: device.runtime } : { udid };
|
||||
} catch {
|
||||
return { udid };
|
||||
}
|
||||
}
|
||||
|
||||
function refusedError(error: string): Error {
|
||||
return error.startsWith('unknown action')
|
||||
? new Error(
|
||||
@@ -127,9 +167,11 @@ function refusedError(error: string): Error {
|
||||
|
||||
export async function connectHostActionsAsync(
|
||||
access: PreviewApiAccess,
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS, signal }: { timeoutMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<HostActionConnection> {
|
||||
const udid = await fetchSimulatorUdidAsync(access);
|
||||
signal?.throwIfAborted();
|
||||
const udid = await fetchSimulatorUdidAsync(access, signal);
|
||||
signal?.throwIfAborted();
|
||||
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
|
||||
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(
|
||||
@@ -144,6 +186,7 @@ export async function connectHostActionsAsync(
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
const pending = new Map<number, Pending>();
|
||||
const controller = new AbortController();
|
||||
let nextId = 1;
|
||||
let lost: Error | null = null;
|
||||
let ready = false;
|
||||
@@ -158,30 +201,39 @@ export async function connectHostActionsAsync(
|
||||
}
|
||||
|
||||
function loseConnection(error: Error): void {
|
||||
lost ??= error;
|
||||
if (lost) {
|
||||
return;
|
||||
}
|
||||
lost = error;
|
||||
controller.abort(error);
|
||||
for (const id of [...pending.keys()]) {
|
||||
settle(id)?.reject(error);
|
||||
}
|
||||
ws.terminate();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
|
||||
)
|
||||
);
|
||||
ws.terminate();
|
||||
}, PREVIEW_API_TIMEOUT_MS);
|
||||
const fail = (error: Error): void => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
loseConnection(error);
|
||||
if (!ready) {
|
||||
ws.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = (): void => {
|
||||
fail(
|
||||
signal?.reason instanceof Error ? signal.reason : new Error('Simulator setup canceled.')
|
||||
);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
ws.once('close', () => signal?.removeEventListener('abort', onAbort));
|
||||
|
||||
ws.on('message', data => {
|
||||
let message: unknown;
|
||||
try {
|
||||
@@ -220,7 +272,9 @@ export async function connectHostActionsAsync(
|
||||
res.destroy();
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
res.statusCode === 404
|
||||
? PREVIEW_UNAVAILABLE_MESSAGE
|
||||
: `The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -240,28 +294,42 @@ export async function connectHostActionsAsync(
|
||||
)
|
||||
);
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
udid,
|
||||
async requestAsync(action, params = {}) {
|
||||
if (lost) {
|
||||
throw lost;
|
||||
}
|
||||
const id = nextId++;
|
||||
const message = JSON.stringify({ id, action, params: { ...params, udid } });
|
||||
return await new Promise<HostActionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(id)?.reject(
|
||||
loseConnection(
|
||||
new Error(
|
||||
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
|
||||
try {
|
||||
ws.send(message, error => {
|
||||
if (error) {
|
||||
loseConnection(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
loseConnection(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
},
|
||||
close() {
|
||||
ws.close();
|
||||
loseConnection(new Error('The simulator action connection was closed.'));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -274,7 +342,7 @@ export async function fetchHostActionCatalogAsync(
|
||||
try {
|
||||
body = JSON.parse(result.stdout);
|
||||
} catch {}
|
||||
const catalog = Catalog.safeParse(body);
|
||||
const catalog = HostActionCatalogSchema.safeParse(body);
|
||||
if (result.exitCode !== 0 || !catalog.success) {
|
||||
throw new Error(
|
||||
'The simulator did not return its list of actions. Start a new session with `eas simulator:start` to get a newer serve-sim version.'
|
||||
@@ -447,12 +515,16 @@ export async function assertHostActionOutputAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostActionUploadId(localPath: string): string {
|
||||
return `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
}
|
||||
|
||||
export async function uploadFileAsync(
|
||||
connection: HostActionConnection,
|
||||
localPath: string,
|
||||
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
|
||||
): Promise<{ uploadId: string; path: string }> {
|
||||
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
const uploadId = createHostActionUploadId(localPath);
|
||||
let remotePath = '';
|
||||
let first = true;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const Action = z.strictObject({
|
||||
action: z.string().min(1),
|
||||
params: z.record(z.string(), z.json()).default({}),
|
||||
file: z.string().min(1).optional(),
|
||||
output: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const Config = z.strictObject({
|
||||
version: z.literal(1),
|
||||
start: z.record(z.string(), z.json()).optional(),
|
||||
actions: z.array(Action).min(1).optional(),
|
||||
});
|
||||
|
||||
export type SimulatorSessionConfig = z.infer<typeof Config> & { path: string };
|
||||
|
||||
export async function readSimulatorSessionConfigAsync(
|
||||
filePath: string
|
||||
): Promise<SimulatorSessionConfig> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await fs.readJson(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not read simulator configuration ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
const parsed = Config.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map(issue => `${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Invalid simulator configuration ${absolutePath}: ${detail}`);
|
||||
}
|
||||
return { ...parsed.data, path: absolutePath };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Interfaces, Parser } from '@oclif/core';
|
||||
|
||||
import { preflightControlConfigFilesAsync } from './controlConfig';
|
||||
import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig';
|
||||
|
||||
/** Reparse the effective values using the command's own validation, options and defaults. */
|
||||
export async function resolveStartConfigAsync(
|
||||
argv: string[],
|
||||
definitions: Interfaces.FlagInput
|
||||
): Promise<{ argv: string[]; config?: SimulatorSessionConfig }> {
|
||||
if (!argv.some(arg => arg === '--import' || arg.startsWith('--import='))) {
|
||||
return { argv };
|
||||
}
|
||||
// Relationships are checked after file values and explicit CLI overrides are combined.
|
||||
const relaxed = Object.fromEntries(
|
||||
Object.entries(definitions).map(([name, flag]) => [
|
||||
name,
|
||||
{
|
||||
...flag,
|
||||
dependsOn: undefined,
|
||||
exclusive: undefined,
|
||||
exactlyOne: undefined,
|
||||
atLeastOne: undefined,
|
||||
relationships: undefined,
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
const parsed = await Parser.parse(argv, { flags: relaxed });
|
||||
if (parsed.flags.import === undefined) {
|
||||
return { argv };
|
||||
}
|
||||
if (!parsed.flags.import.trim()) {
|
||||
throw new Error('--import requires a non-empty configuration file path.');
|
||||
}
|
||||
const config = await readSimulatorSessionConfigAsync(parsed.flags.import);
|
||||
const values: Record<string, unknown> = { ...config.start };
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!Object.hasOwn(definitions, key) || ['import', 'json', 'non-interactive'].includes(key)) {
|
||||
throw new Error(`Unknown or invocation-only start setting "${key}" in ${config.path}.`);
|
||||
}
|
||||
}
|
||||
const explicit = Object.entries(parsed.flags).filter(
|
||||
([name, value]) => value !== undefined && !parsed.metadata.flags[name]?.setFromDefault
|
||||
);
|
||||
const explicitNames = new Set(explicit.map(([name]) => name));
|
||||
const removed = new Set<string>();
|
||||
for (const [name, value] of explicit) {
|
||||
for (const excluded of definitions[name].exclusive ?? []) {
|
||||
if (!explicitNames.has(excluded)) {
|
||||
delete values[excluded];
|
||||
removed.add(excluded);
|
||||
}
|
||||
}
|
||||
values[name] = value;
|
||||
}
|
||||
// SDK selection belongs to the saved Expo Go app source, not a replacement build.
|
||||
if (removed.has('expo-go') && !explicitNames.has('sdk-version')) {
|
||||
delete values['sdk-version'];
|
||||
}
|
||||
if (
|
||||
explicitNames.has('network-capture') &&
|
||||
values['network-capture'] === false &&
|
||||
!explicitNames.has('network-capture-field')
|
||||
) {
|
||||
delete values['network-capture-field'];
|
||||
}
|
||||
const effectiveArgv: string[] = [];
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const flag = definitions[name];
|
||||
if (flag.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Start setting "${name}" must be a boolean.`);
|
||||
}
|
||||
if (value) {
|
||||
effectiveArgv.push(`--${name}`);
|
||||
} else if (flag.allowNo) {
|
||||
effectiveArgv.push(`--no-${name}`);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(value) ? value : [value];
|
||||
if (Array.isArray(value) && !flag.multiple) {
|
||||
throw new Error(`Start setting "${name}" does not accept multiple values.`);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (typeof item !== 'string' && typeof item !== 'number') {
|
||||
throw new Error(`Start setting "${name}" must contain a string or number.`);
|
||||
}
|
||||
effectiveArgv.push(`--${name}=${item}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await preflightControlConfigFilesAsync(config);
|
||||
return { argv: effectiveArgv, config };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
ControlConfigProgress,
|
||||
ControlConfigResult,
|
||||
executeControlConfigAsync,
|
||||
prepareControlConfigAsync,
|
||||
} from './controlConfig';
|
||||
import { connectHostActionsAsync, fetchHostActionCatalogAsync } from './hostActions';
|
||||
import { SimulatorSessionConfig } from './sessionConfig';
|
||||
import { PreviewApiAccess } from './utils';
|
||||
|
||||
export async function applyStartConfigAsync(
|
||||
access: PreviewApiAccess,
|
||||
config: SimulatorSessionConfig,
|
||||
{
|
||||
signal,
|
||||
onProgress,
|
||||
}: { signal: AbortSignal; onProgress?: (progress: ControlConfigProgress) => void }
|
||||
): Promise<ControlConfigResult[]> {
|
||||
const connection = await connectHostActionsAsync(access, { signal });
|
||||
try {
|
||||
const catalog = await fetchHostActionCatalogAsync(connection);
|
||||
const prepared = await prepareControlConfigAsync(catalog, config.path, connection.udid, config);
|
||||
signal.throwIfAborted();
|
||||
return await executeControlConfigAsync(connection, prepared, { onProgress });
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AccountFragment,
|
||||
CurrentUserQuery,
|
||||
PartnerActor,
|
||||
Permission,
|
||||
@@ -59,10 +60,11 @@ export function getCreatableAccountNames(actor: Actor): string[] {
|
||||
if (actor.__typename === 'Robot') {
|
||||
return creatableAccounts.map(it => it.name);
|
||||
}
|
||||
const personalAccountId = getPersonalAccount(actor)?.id;
|
||||
return [
|
||||
...creatableAccounts.filter(a => a.ownerUserActor?.id === actor.id),
|
||||
...creatableAccounts.filter(a => a.ownerUserActor && a.ownerUserActor.id !== actor.id),
|
||||
...creatableAccounts.filter(a => !a.ownerUserActor),
|
||||
...creatableAccounts.filter(a => a.id === personalAccountId),
|
||||
...creatableAccounts.filter(a => a.ownerUserActor && a.id !== personalAccountId),
|
||||
...creatableAccounts.filter(a => !a.ownerUserActor && a.id !== personalAccountId),
|
||||
].map(it => it.name);
|
||||
}
|
||||
|
||||
@@ -104,3 +106,24 @@ export function getActorUsername(actor?: Actor): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the personal account of an actor. Use this helper instead of
|
||||
* assuming that a user's personal account name equals their username.
|
||||
*
|
||||
* Returns `null` for actors without a personal account (e.g. robots), and when the session's
|
||||
* scope doesn't include the personal account.
|
||||
*/
|
||||
export function getPersonalAccount(actor?: Actor): AccountFragment | null {
|
||||
switch (actor?.__typename) {
|
||||
case 'User':
|
||||
case 'SSOUser':
|
||||
return actor.accounts.find(account => account.ownerUserActor?.id === actor.id) ?? null;
|
||||
case 'PartnerActor':
|
||||
// ownerUserActor only resolves to a User or SSOUser, so match on the primary account id
|
||||
return actor.accounts.find(account => account.id === actor.primaryAccount.id) ?? null;
|
||||
case 'Robot':
|
||||
case undefined:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getActorDisplayName,
|
||||
getCannotCreateProjectsOnAccountNameReason,
|
||||
getCreatableAccountNames,
|
||||
getPersonalAccount,
|
||||
} from '../User';
|
||||
|
||||
const userStub: Actor = {
|
||||
@@ -187,3 +188,84 @@ describe('getCreatableAccountNames', () => {
|
||||
expect(getCreatableAccountNames(actor)).toEqual(['developer-org']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPersonalAccount', () => {
|
||||
const personalAccount = {
|
||||
id: 'personal_account_id',
|
||||
name: 'renamed-personal',
|
||||
ownerUserActor: { id: 'userId', username: 'username' },
|
||||
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
|
||||
};
|
||||
|
||||
it('returns the account owned by the user, even when its name differs from the username', () => {
|
||||
const actor: Actor = {
|
||||
...userStub,
|
||||
accounts: [
|
||||
{
|
||||
id: 'org_account_id',
|
||||
name: 'username',
|
||||
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
|
||||
},
|
||||
personalAccount,
|
||||
],
|
||||
};
|
||||
|
||||
expect(getPersonalAccount(actor)).toBe(personalAccount);
|
||||
});
|
||||
|
||||
it('returns the account owned by an SSO user', () => {
|
||||
const actor: Actor = {
|
||||
...ssoUserStub,
|
||||
accounts: [
|
||||
{
|
||||
...personalAccount,
|
||||
ownerUserActor: { id: 'ssoUserId', username: 'ssoUsername' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getPersonalAccount(actor)?.id).toBe('personal_account_id');
|
||||
});
|
||||
|
||||
it('does not return a team account owned by another user', () => {
|
||||
const actor: Actor = {
|
||||
...userStub,
|
||||
accounts: [
|
||||
{
|
||||
...personalAccount,
|
||||
ownerUserActor: { id: 'otherUserId', username: 'other-user' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getPersonalAccount(actor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the personal account is not in the accounts list (e.g. scoped session)', () => {
|
||||
expect(getPersonalAccount({ ...userStub, accounts: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the primary account of a partner actor', () => {
|
||||
const partnerAccount = {
|
||||
id: 'partner_account_id',
|
||||
name: 'partner-account',
|
||||
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
|
||||
};
|
||||
const actor: Actor = {
|
||||
__typename: 'PartnerActor',
|
||||
id: 'partnerActorId',
|
||||
username: 'partner-user',
|
||||
primaryAccount: { id: 'partner_account_id' },
|
||||
accounts: [partnerAccount],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
};
|
||||
|
||||
expect(getPersonalAccount(actor)).toBe(partnerAccount);
|
||||
});
|
||||
|
||||
it('returns null for robots and missing actors', () => {
|
||||
expect(getPersonalAccount(robotStub)).toBeNull();
|
||||
expect(getPersonalAccount()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
||||
|
||||
import { Permission, Role } from '../graphql/generated';
|
||||
import Log from '../log';
|
||||
import { Actor } from './User';
|
||||
import { Actor, getPersonalAccount } from './User';
|
||||
import { getRoleForPermissions } from './roles';
|
||||
|
||||
/**
|
||||
@@ -16,8 +16,9 @@ export function logAccountPermissions(actor: Actor): { hasLimitedAccount: boolea
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more, or if the credentials used for
|
||||
// this command limit the permissions on it
|
||||
const personalAccount = getPersonalAccount(actor);
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
account => account.id !== personalAccount?.id
|
||||
);
|
||||
const hasLimitedAccount = actor.accounts.some(isLimitedByCredentials);
|
||||
if (accountExcludingPersonalAccount.length === 0 && !hasLimitedAccount) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.4.1",
|
||||
"version": "27.5.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.4.1",
|
||||
"@expo/eas-build-job": "27.5.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.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user