mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0db455bdf | ||
|
|
eda1e20b33 | ||
|
|
136386db6b | ||
|
|
4db642380e | ||
|
|
a30678faa6 | ||
|
|
f949f3430e | ||
|
|
e61a563b70 | ||
|
|
150cd21174 | ||
|
|
4b9e792e91 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Fix simulator control help routing, preserve literal arguments after `--`, and require `host:` for simulator-host file paths. Package local `.app` bundles when the session supports app archives.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
"STAGING": "25.2.0",
|
||||
"PRODUCTION": "25.2.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/downloader": "25.2.0",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/template-file": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "25.1.1"
|
||||
"@expo/steps": "25.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
+246
-144
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/eas-json": "25.1.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/eas-json": "25.2.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -185,6 +185,7 @@
|
||||
],
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"helpClass": "./build/help",
|
||||
"plugins": [
|
||||
"@oclif/plugin-autocomplete",
|
||||
"@expo/plugin-help",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
let run: jest.SpyInstance;
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(showHelp).toHaveBeenCalledWith(argv);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
} from '../../../simulator/hostActions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControl from '../control';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/hostActions', () => ({
|
||||
...jest.requireActual('../../../simulator/hostActions'),
|
||||
connectHostActionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const catalog: HostActionCatalog = require('../../../simulator/__tests__/fixtures/hostActionCatalog.json');
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockConnectHostActionsAsync = jest.mocked(connectHostActionsAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeDeviceRunSession(
|
||||
overrides: Partial<DeviceRunSessionById> = {}
|
||||
): DeviceRunSessionById & { publicPreview: boolean } {
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
tags: [],
|
||||
publicPreview: false,
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
startedAt: '2025-01-01T00:00:05.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2025-01-01T00:01:00.000Z',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
slug: 'testapp',
|
||||
ownerAccount: { id: 'account-123', name: 'testuser' },
|
||||
},
|
||||
artifacts: [],
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
},
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorControl, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectDir = '/test/project';
|
||||
const ok = (stdout: string): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
let actionResult: HostActionResult;
|
||||
let served: HostActionCatalog;
|
||||
let tmpDir: string;
|
||||
const requestAsync = jest.fn(
|
||||
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
|
||||
switch (action) {
|
||||
case 'actions.describe':
|
||||
return ok(JSON.stringify(served));
|
||||
case 'upload.append':
|
||||
return ok(`/tmp/serve-sim-uploads/${params.uploadId}\n`);
|
||||
case 'upload.remove':
|
||||
return ok('');
|
||||
case 'file.readBase64':
|
||||
return ok(Buffer.from('png bytes').toString('base64'));
|
||||
default:
|
||||
return actionResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
|
||||
actionResult = ok('off\n');
|
||||
served = catalog;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorControl {
|
||||
const command = new SimulatorControl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function actionRequests(): Parameters<typeof requestAsync>[] {
|
||||
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
|
||||
}
|
||||
|
||||
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
EAS_SIMULATOR_SESSION_ID: 'env-session',
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
createCommand(['hardwareKeyboard.set', 'off', ...flags]).runAsync()
|
||||
).rejects.toThrow('--id needs a simulator session ID.');
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the environment session when --id is absent', async () => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
EAS_SIMULATOR_SESSION_ID: 'env-session',
|
||||
});
|
||||
try {
|
||||
await createCommand(['hardwareKeyboard.set', 'off']).runAsync();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'env-session');
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs an action from the catalog with its values and prints the output', async () => {
|
||||
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
|
||||
apiUrl: 'https://sim.example.test',
|
||||
token: 'preview-token',
|
||||
});
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('off');
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
|
||||
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
|
||||
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
|
||||
expect(lines.flatMap(line => line.trim().split(/\s+/)).sort()).toEqual(
|
||||
Object.keys(catalog).sort()
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
|
||||
await createCommand(['permissions.set', help, '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' Grant, revoke or reset one permission for an app.'
|
||||
);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' eas simulator:control permissions.set <action> <service> <bundleId>'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows local file guidance without validating output or uploading files', async () => {
|
||||
await createCommand([
|
||||
'screenshot.capture',
|
||||
'--help',
|
||||
'--output',
|
||||
'/missing/folder/screen.png',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' eas simulator:control screenshot.capture [fileName]'
|
||||
);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' --output <path> Local filename or existing directory.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prints the host action schema as JSON for help', async () => {
|
||||
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
action: 'app.install',
|
||||
...catalog['app.install'],
|
||||
});
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects unknown actions in help and closes the connection', async () => {
|
||||
await expect(
|
||||
createCommand(['unknown.action', '--help', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Unknown simulator action "unknown.action"');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prints parsed JSON output', async () => {
|
||||
actionResult = ok('{"voiceover":"off"}\n');
|
||||
|
||||
await createCommand(['permissions.list', '--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
action: 'permissions.list',
|
||||
output: { voiceover: 'off' },
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a local file for an action that takes one and removes it after', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the upload ID to an action that asks for it', async () => {
|
||||
const localFile = path.join(tmpDir, 'session.json');
|
||||
await fs.writeFile(localFile, '{}');
|
||||
actionResult = ok('');
|
||||
served = {
|
||||
...catalog,
|
||||
'app.file.upload': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
bundleId: { type: 'string' },
|
||||
relativePath: { type: 'string' },
|
||||
uploadId: { type: 'string' },
|
||||
},
|
||||
upload: 'uploadId',
|
||||
uploadAs: 'id',
|
||||
},
|
||||
};
|
||||
|
||||
await createCommand([
|
||||
'app.file.upload',
|
||||
'com.example.app',
|
||||
'Documents/session.json',
|
||||
localFile,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
['upload.append', { uploadId, data: Buffer.from('{}').toString('base64'), first: true }],
|
||||
[
|
||||
'app.file.upload',
|
||||
{ bundleId: 'com.example.app', relativePath: 'Documents/session.json', uploadId },
|
||||
],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an upload that the action goes on reading', async () => {
|
||||
const localFile = path.join(tmpDir, 'feed.mp4');
|
||||
await fs.writeFile(localFile, 'video bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
expect(requests.map(([action]) => action)).toEqual(['upload.append', 'camera.switch']);
|
||||
expect(requests[1][1]).toEqual({
|
||||
source: 'file',
|
||||
target: `/tmp/serve-sim-uploads/${requests[0][1]?.uploadId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a kept upload when the action fails', async () => {
|
||||
const localFile = path.join(tmpDir, 'feed.mp4');
|
||||
await fs.writeFile(localFile, 'video bytes');
|
||||
actionResult = { stdout: '', stderr: 'No such app.\n', exitCode: 1 };
|
||||
|
||||
await expect(
|
||||
createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('The simulator could not run camera.switch: No such app.');
|
||||
expect(actionRequests().map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'camera.switch',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a webcam name through without looking for a local file', async () => {
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand([
|
||||
'camera.switch',
|
||||
'webcam',
|
||||
'FaceTime HD Camera',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(actionRequests()).toEqual([
|
||||
['camera.switch', { source: 'webcam', target: 'FaceTime HD Camera' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a relative file that does not exist on this computer', async () => {
|
||||
await expect(
|
||||
createCommand(['app.install', './MyAp.ipa', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('./MyAp.ipa does not exist on this computer.');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses --output for an action that returns no file before it runs', async () => {
|
||||
await expect(
|
||||
createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--output',
|
||||
'./out',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync()
|
||||
).rejects.toThrow('hardwareKeyboard.set does not return a file, so --output does not apply.');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a missing absolute local file without running an action', async () => {
|
||||
const missing = path.join(tmpDir, 'missing.png');
|
||||
await expect(
|
||||
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes an explicit host path through without uploading a matching local file', async () => {
|
||||
const localFile = path.join(tmpDir, 'existing.png');
|
||||
await fs.writeFile(localFile, 'local bytes');
|
||||
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
|
||||
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
|
||||
});
|
||||
|
||||
it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
|
||||
await expect(
|
||||
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('absolute');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['text=hello', '--', '--help'])(
|
||||
'preserves the literal value after the separator: %s',
|
||||
async value => {
|
||||
served = {
|
||||
...catalog,
|
||||
'clipboard.write': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, text: { type: 'string' } },
|
||||
required: ['udid', 'text'],
|
||||
},
|
||||
};
|
||||
await createCommand(['clipboard.write', '--id', 'session-123', '--', value]).runAsync();
|
||||
expect(actionRequests()).toEqual([['clipboard.write', { text: value }]]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not treat a flag value of -- as the literal separator', async () => {
|
||||
served = {
|
||||
...catalog,
|
||||
'clipboard.write': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, text: { type: 'string' } },
|
||||
required: ['udid', 'text'],
|
||||
},
|
||||
};
|
||||
await createCommand(['clipboard.write', 'text=hello', '--id', '--']).runAsync();
|
||||
expect(actionRequests()).toEqual([['clipboard.write', { text: 'hello' }]]);
|
||||
});
|
||||
|
||||
it('keeps named arguments before the separator and positional values after it', async () => {
|
||||
await createCommand([
|
||||
'permissions.set',
|
||||
'action=grant',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--',
|
||||
'service=camera',
|
||||
'bundleId=literal',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([
|
||||
[
|
||||
'permissions.set',
|
||||
{ action: 'grant', service: 'service=camera', bundleId: 'bundleId=literal' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'packages a local app bundle and cleans it after failure=%s',
|
||||
async fails => {
|
||||
const bundle = path.join(tmpDir, 'My App.app');
|
||||
await fs.mkdir(bundle);
|
||||
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
|
||||
served = {
|
||||
...catalog,
|
||||
'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' },
|
||||
};
|
||||
const directories = await fs.readdir(os.tmpdir());
|
||||
actionResult = fails ? { stdout: '', stderr: 'install rejected', exitCode: 1 } : ok('');
|
||||
const command = createCommand([
|
||||
'app.install',
|
||||
`${bundle}/`,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
if (fails) {
|
||||
await expect(command).rejects.toThrow('install rejected');
|
||||
} else {
|
||||
await command;
|
||||
}
|
||||
const requests = actionRequests();
|
||||
expect(requests.map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'app.install',
|
||||
'upload.remove',
|
||||
]);
|
||||
const data = Buffer.concat(
|
||||
requests
|
||||
.filter(([action]) => action === 'upload.append')
|
||||
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
|
||||
);
|
||||
const file = path.join(tmpDir, 'received.tgz');
|
||||
await fs.writeFile(file, data);
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file,
|
||||
onReadEntry: entry => {
|
||||
entries.push(entry.path);
|
||||
},
|
||||
});
|
||||
expect(entries).toContain('My App.app/Info.plist');
|
||||
const newDirectories = (await fs.readdir(os.tmpdir())).filter(
|
||||
name => name.startsWith('eas-simulator-app-') && !directories.includes(name)
|
||||
);
|
||||
expect(newDirectories).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('packages the contents of an app bundle reached through a local symlink', async () => {
|
||||
const actual = path.join(tmpDir, 'Actual');
|
||||
const link = path.join(tmpDir, 'Linked.app');
|
||||
await fs.mkdir(actual);
|
||||
await fs.writeFile(path.join(actual, 'Info.plist'), 'app plist');
|
||||
await fs.symlink(actual, link);
|
||||
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
|
||||
await createCommand(['app.install', link, '--id', 'session-123']).runAsync();
|
||||
const data = Buffer.concat(
|
||||
actionRequests()
|
||||
.filter(([action]) => action === 'upload.append')
|
||||
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
|
||||
);
|
||||
const file = path.join(tmpDir, 'received.tgz');
|
||||
await fs.writeFile(file, data);
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file,
|
||||
onReadEntry: entry => {
|
||||
entries.push(entry.path);
|
||||
},
|
||||
});
|
||||
expect(entries).toContain('Linked.app/Info.plist');
|
||||
});
|
||||
|
||||
it('cleans a packaged bundle when uploading fails', async () => {
|
||||
const bundle = path.join(tmpDir, 'Upload.app');
|
||||
await fs.mkdir(bundle);
|
||||
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
|
||||
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
|
||||
const before = await fs.readdir(os.tmpdir());
|
||||
requestAsync
|
||||
.mockResolvedValueOnce(ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'upload rejected', exitCode: 1 });
|
||||
await expect(
|
||||
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('upload rejected');
|
||||
expect(actionRequests().map(([action]) => action)).toEqual(['upload.append', 'upload.remove']);
|
||||
expect(
|
||||
(await fs.readdir(os.tmpdir())).filter(
|
||||
name => name.startsWith('eas-simulator-app-') && !before.includes(name)
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses bundle upload to a host that does not advertise app archives', async () => {
|
||||
const bundle = path.join(tmpDir, 'My.app');
|
||||
await fs.mkdir(bundle);
|
||||
await expect(
|
||||
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not support app archives');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('continues rejecting unrelated folders', async () => {
|
||||
await expect(
|
||||
createCommand(['media.add', tmpDir, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('is a folder');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n');
|
||||
|
||||
await createCommand([
|
||||
'screenshot.capture',
|
||||
'--output',
|
||||
outputPath,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(`Saved ${outputPath}`);
|
||||
});
|
||||
|
||||
it('refuses an action that the session does not list', async () => {
|
||||
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an established connection when catalog setup fails', async () => {
|
||||
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
|
||||
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'did not return its list of actions'
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
);
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Simulator session session-123 is stopped');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an Android session', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('available only for iOS sessions');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a session without a preview server API', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewToken: 'preview-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not expose a preview server');
|
||||
});
|
||||
|
||||
it('reports the simulator error when the action fails', async () => {
|
||||
actionResult = {
|
||||
stdout: '',
|
||||
stderr: 'Simulator could not change the hinge angle.\n',
|
||||
exitCode: 1,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createCommand(['hinge.set', 'half', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow(
|
||||
'The simulator could not run hinge.set: Simulator could not change the hinge angle.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../../simulator/serveSimHost';
|
||||
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/serveSimHost', () => ({
|
||||
...jest.requireActual('../../../simulator/serveSimHost'),
|
||||
getServeSimDeviceUdidAsync: jest.fn(),
|
||||
runServeSimHostActionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
const SPRINGBOARD_RESTART = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
|
||||
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
|
||||
|
||||
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.Argent,
|
||||
platform: AppPlatform.Ios,
|
||||
remoteConfig: {
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: 'preview-token',
|
||||
},
|
||||
...overrides,
|
||||
} as DeviceRunSessionById;
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(parseSimctlArgv, () => {
|
||||
it('reads --id before the simctl arguments', () => {
|
||||
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
|
||||
id: 'session-1',
|
||||
args: ['ui', 'appearance', 'dark'],
|
||||
});
|
||||
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
|
||||
id: 'session-1',
|
||||
args: SPRINGBOARD_RESTART,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes simctl flags through untouched', () => {
|
||||
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
|
||||
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe(SimulatorSimctl, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeSession());
|
||||
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
|
||||
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorSimctl {
|
||||
const command = new SimulatorSimctl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('restarts SpringBoard on the session simulator', async () => {
|
||||
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
|
||||
'springboard.restart',
|
||||
{ udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the appearance on the session from the env file', async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
|
||||
|
||||
await createCommand(['ui', 'appearance', 'dark']).runAsync();
|
||||
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'appearance.set',
|
||||
{ value: 'dark', udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsupported command before fetching the session', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
|
||||
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an Android session', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('only supports iOS');
|
||||
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('is stopped, not running');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../../simulator/hostActions';
|
||||
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorControl extends EasCommand {
|
||||
static override aliases = ['sim:control'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`.';
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
'$ eas simulator:control',
|
||||
'$ eas simulator:control --id <session-id>',
|
||||
'$ eas simulator:control screenshot.capture --help',
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.app',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png --id <session-id>',
|
||||
];
|
||||
|
||||
static override args = {
|
||||
action: Args.string({
|
||||
description:
|
||||
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are positional literals, including text that starts with -- or name=.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
help: Flags.boolean({
|
||||
char: 'h',
|
||||
description: 'Show command help, or usage for the selected action from the session.',
|
||||
}),
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
list: Flags.boolean({
|
||||
description: 'List the actions the session supports.',
|
||||
}),
|
||||
output: Flags.string({
|
||||
description:
|
||||
'Local path to save a returned file. For screenshots, --output ./screenshot.png sets the local filename; omit it to save with a generated filename in the current directory.',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, argv, flags } = await this.parse(SimulatorControl);
|
||||
let literalFrom = argv.length - 1;
|
||||
for (let index = 0; index < this.argv.length; index++) {
|
||||
const value = this.argv[index];
|
||||
if (value === '--') {
|
||||
literalFrom = argv.length - 1 - (this.argv.length - index - 1);
|
||||
break;
|
||||
}
|
||||
// A standalone -- consumed as a flag value is not an argument separator.
|
||||
const flag = value.startsWith('--')
|
||||
? SimulatorControl.flags[value.slice(2) as keyof typeof SimulatorControl.flags]
|
||||
: undefined;
|
||||
if (flag?.type === 'option') {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
if (flags.id === '') {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
const action = flags.list && !flags.help ? undefined : args.action;
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorControl, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora(`Connecting to simulator session ${sessionId}`).start();
|
||||
let connection: HostActionConnection | undefined;
|
||||
try {
|
||||
let catalog: HostActionCatalog;
|
||||
try {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(assertControllableSession(session));
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
|
||||
throw err;
|
||||
}
|
||||
if (!action) {
|
||||
this.printCatalog(catalog, jsonFlag);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
|
||||
);
|
||||
}
|
||||
if (flags.help) {
|
||||
this.printActionHelp(action, entry, jsonFlag);
|
||||
return;
|
||||
}
|
||||
await this.runActionAsync(connection, entry, {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
literalFrom,
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(catalog);
|
||||
return;
|
||||
}
|
||||
const names = Object.keys(catalog).sort();
|
||||
const width = Math.max(0, ...names.map(name => name.length)) + 3;
|
||||
const columns = Math.max(1, Math.min(3, Math.floor((process.stdout.columns || 80) / width)));
|
||||
const rows = Math.ceil(names.length / columns);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cells = Array.from({ length: columns }, (_, column) =>
|
||||
(names[row + column * rows] ?? '').padEnd(width)
|
||||
);
|
||||
Log.log(cells.join('').trimEnd());
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log('For action usage: eas simulator:control <action> --help');
|
||||
}
|
||||
|
||||
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ ...entry, action });
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold(action)}`);
|
||||
if (entry.description) {
|
||||
Log.log(` ${entry.description}`);
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold('USAGE')}`);
|
||||
Log.log(` eas simulator:control ${formatHostActionUsage(action, entry)}`);
|
||||
const outputFlag = entry.output === 'base64' ? '--output <path>' : '[--output <path>]';
|
||||
Log.log(` ${entry.output ? `${outputFlag} ` : ''}[--id <session-id>] [--json]`);
|
||||
Log.newLine();
|
||||
Log.log(` ${chalk.bold('OPTIONS')}`);
|
||||
Log.log(` --id <session-id> Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`);
|
||||
Log.log(' --json Print the action schema as JSON. Implies --non-interactive.');
|
||||
if (entry.output) {
|
||||
Log.log(
|
||||
entry.output === 'base64'
|
||||
? ' --output <path> Required local filename for the returned file.'
|
||||
: ' --output <path> Local filename or existing directory.'
|
||||
);
|
||||
if (entry.output === 'path') {
|
||||
Log.log(' Omit to save with the generated filename.');
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
' Action arguments refer to the simulator host. Use --output for the local filename.'
|
||||
);
|
||||
}
|
||||
Log.log(' Values after -- are positional literals.');
|
||||
if (entry.upload) {
|
||||
Log.log(' File paths are local. Use host:/absolute/path for files on the simulator host.');
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private async runActionAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
{
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
literalFrom,
|
||||
outputPath,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
literalFrom: number;
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values, literalFrom);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(action, entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
let output: string;
|
||||
let savedFile: string | null;
|
||||
try {
|
||||
if (localFile) {
|
||||
spinner.text = `Uploading ${localFile.path}`;
|
||||
const upload = await uploadFileAsync(connection, localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
spinner.text = `Running ${action} on simulator session ${sessionId}`;
|
||||
}
|
||||
const result = await connection.requestAsync(action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
|
||||
output = savedFile ? '' : result.stdout.trim();
|
||||
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
|
||||
throw err;
|
||||
} finally {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
await localFile?.cleanupAsync?.();
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: sessionId,
|
||||
action,
|
||||
output: parseJsonOutput(output),
|
||||
file: savedFile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (savedFile) {
|
||||
Log.log(`Saved ${savedFile}`);
|
||||
} else if (output) {
|
||||
Log.log(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
action: string,
|
||||
{ upload: param, uploadWhen = {}, uploadArchive }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string; cleanupAsync?: () => Promise<void> } | 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:')) {
|
||||
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.');
|
||||
}
|
||||
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()) {
|
||||
if (action !== 'app.install' || path.extname(path.resolve(value)) !== '.app') {
|
||||
throw new Error(
|
||||
`${value} is a folder. Pass the path of a file or an app.install .app bundle.`
|
||||
);
|
||||
}
|
||||
if (uploadArchive !== 'tar.gz') {
|
||||
throw new Error(
|
||||
'This simulator host does not support app archives. Start a session with an updated serve-sim package.'
|
||||
);
|
||||
}
|
||||
const bundle = await fs.realpath(value);
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-simulator-app-'));
|
||||
const archive = path.join(directory, 'app.tar.gz');
|
||||
const cleanupAsync = async (): Promise<void> => {
|
||||
await fs.remove(directory);
|
||||
};
|
||||
try {
|
||||
await tar.create(
|
||||
{
|
||||
cwd: bundle,
|
||||
file: archive,
|
||||
prefix: path.basename(path.resolve(value)),
|
||||
gzip: true,
|
||||
portable: true,
|
||||
},
|
||||
['.']
|
||||
);
|
||||
return { param, path: archive, cleanupAsync };
|
||||
} catch (error) {
|
||||
await cleanupAsync();
|
||||
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:.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
}
|
||||
|
||||
function assertControllableSession(
|
||||
session: Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>
|
||||
): PreviewApiAccess {
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} runs on ${session.platform.toLowerCase()}. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../simulator/serveSimHost';
|
||||
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
|
||||
|
||||
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
|
||||
let id: string | undefined;
|
||||
let index = 0;
|
||||
while (index < rawArgv.length) {
|
||||
const arg = rawArgv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (arg === '--id') {
|
||||
id = rawArgv[index + 1];
|
||||
if (!id) {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
index += 2;
|
||||
} else if (arg.startsWith('--id=')) {
|
||||
id = arg.slice('--id='.length);
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { id, args: rawArgv.slice(index) };
|
||||
}
|
||||
|
||||
export default class SimulatorSimctl extends EasCommand {
|
||||
static override aliases = ['sim:simctl'];
|
||||
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => `\`${command}\``
|
||||
).join(', ')}`;
|
||||
static override strict = false;
|
||||
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const rawArgv = [...this.argv];
|
||||
await this.parse(SimulatorSimctl, []);
|
||||
const { id: flagId, args } = parseSimctlArgv(rawArgv);
|
||||
|
||||
const hostAction = resolveSimctlHostAction(args);
|
||||
if (!hostAction) {
|
||||
throw new Error(
|
||||
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => ` ${command}`
|
||||
).join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
|
||||
}
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
|
||||
);
|
||||
}
|
||||
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
|
||||
if (!host) {
|
||||
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
|
||||
}
|
||||
|
||||
const udid = await getServeSimDeviceUdidAsync(host);
|
||||
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
|
||||
...hostAction.params,
|
||||
udid,
|
||||
});
|
||||
if (stdout) {
|
||||
Log.log(stdout.trimEnd());
|
||||
}
|
||||
if (stderr) {
|
||||
Log.warn(stderr.trimEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Help, Parser } from '@oclif/core';
|
||||
|
||||
export default class EasHelp extends Help {
|
||||
// eslint-disable-next-line async-protect/async-suffix -- oclif defines the method name.
|
||||
override async showHelp(argv: string[]): Promise<void> {
|
||||
const [command, ...args] = argv;
|
||||
if (command === 'simulator:control' || command === 'sim:control') {
|
||||
const { default: SimulatorControl } = await import('./commands/simulator/control');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
} catch {
|
||||
await super.showHelp(argv);
|
||||
return;
|
||||
}
|
||||
if (parsed.args.action && parsed.flags.help) {
|
||||
await this.config.runCommand('simulator:control', args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await super.showHelp(argv);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"hardwareKeyboard.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"value"
|
||||
],
|
||||
"description": "Connect or disconnect the hardware keyboard."
|
||||
},
|
||||
"caDebug.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"option": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"blended",
|
||||
"copies",
|
||||
"misaligned",
|
||||
"offscreen",
|
||||
"slow-animations"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"option",
|
||||
"value"
|
||||
],
|
||||
"description": "Turn a Core Animation debug option on or off."
|
||||
},
|
||||
"hinge.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fold",
|
||||
"half",
|
||||
"unfold"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"value"
|
||||
],
|
||||
"description": "Fold or unfold a foldable simulator."
|
||||
},
|
||||
"location.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number"
|
||||
},
|
||||
"lng": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"lat",
|
||||
"lng"
|
||||
],
|
||||
"description": "Set the simulated location."
|
||||
},
|
||||
"permissions.list": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid"
|
||||
],
|
||||
"description": "List permissions for one app or for every app."
|
||||
},
|
||||
"permissions.set": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grant",
|
||||
"revoke",
|
||||
"reset"
|
||||
]
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"action",
|
||||
"service",
|
||||
"bundleId"
|
||||
],
|
||||
"description": "Grant, revoke or reset one permission for an app."
|
||||
},
|
||||
"app.install": {
|
||||
"description": "Install an app from an .ipa file.",
|
||||
"args": [
|
||||
"path"
|
||||
],
|
||||
"upload": "path",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"uploadId": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"uploadId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"media.add": {
|
||||
"description": "Add a photo or video to the Photos library.",
|
||||
"args": [
|
||||
"path"
|
||||
],
|
||||
"upload": "path",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"uploadId": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"uploadId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"camera.switch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source",
|
||||
"target"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "webcam"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "placeholder"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"source"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Change the camera feed of an app with a simulated camera.",
|
||||
"args": [
|
||||
"source",
|
||||
"target"
|
||||
],
|
||||
"upload": "target",
|
||||
"uploadWhen": {
|
||||
"source": "file"
|
||||
},
|
||||
"keepUpload": true
|
||||
},
|
||||
"camera.inject": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source",
|
||||
"target"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "webcam"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"mirror": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"const": "placeholder"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Relaunch an app with a simulated camera feed.",
|
||||
"args": [
|
||||
"bundleId",
|
||||
"mirror",
|
||||
"source",
|
||||
"target"
|
||||
],
|
||||
"upload": "target",
|
||||
"uploadWhen": {
|
||||
"source": "file"
|
||||
},
|
||||
"keepUpload": true
|
||||
},
|
||||
"screenshot.capture": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"pattern": "^serve-sim-screenshot-[A-Za-z0-9._-]{1,100}\\.png$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid"
|
||||
],
|
||||
"description": "Take a screenshot.",
|
||||
"output": "path"
|
||||
},
|
||||
"file.readBase64": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\p{C}]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"description": "Download a file from the simulator host.",
|
||||
"output": "base64"
|
||||
},
|
||||
"app.container": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"udid": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"udid",
|
||||
"bundleId"
|
||||
],
|
||||
"description": "Print the path of an installed app bundle."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import * as fetchModule from '../../fetch';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../hostActions';
|
||||
|
||||
const UDID = '90858C9D-BC34-4A0A-93D4-E4939A681A72';
|
||||
const TOKEN = 'preview-token_123';
|
||||
const catalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
|
||||
function entry(name: string): HostActionCatalog[string] {
|
||||
return catalog[name];
|
||||
}
|
||||
|
||||
describe(buildHostActionParams, () => {
|
||||
it('fills parameters in schema order', () => {
|
||||
expect(
|
||||
buildHostActionParams('permissions.set', entry('permissions.set'), [
|
||||
'grant',
|
||||
'camera',
|
||||
'com.example.app',
|
||||
])
|
||||
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
|
||||
});
|
||||
|
||||
it('converts values to the types the schema accepts', () => {
|
||||
expect(
|
||||
buildHostActionParams('location.set', entry('location.set'), ['37.3349', '-122.009'])
|
||||
).toEqual({ lat: 37.3349, lng: -122.009 });
|
||||
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['90'])).toEqual({ value: 90 });
|
||||
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['half'])).toEqual({
|
||||
value: 'half',
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['1'])
|
||||
).toEqual({ value: '1' });
|
||||
expect(buildHostActionParams('location.set', entry('location.set'), ['1e1', '.5'])).toEqual({
|
||||
lat: 10,
|
||||
lng: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads JSON for a parameter that takes a list', () => {
|
||||
const launch = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
bundleId: { type: 'string' },
|
||||
launchArgs: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildHostActionParams('app.launch', launch, ['com.example.app', '["-FIRDebugEnabled"]'])
|
||||
).toEqual({ bundleId: 'com.example.app', launchArgs: ['-FIRDebugEnabled'] });
|
||||
expect(buildHostActionParams('app.launch', launch, ['com.example.app', '[oops'])).toEqual({
|
||||
bundleId: 'com.example.app',
|
||||
launchArgs: '[oops',
|
||||
});
|
||||
});
|
||||
|
||||
it('fills the values in order around the named ones', () => {
|
||||
expect(buildHostActionParams('location.set', entry('location.set'), ['lat=1', '2'])).toEqual({
|
||||
lat: 1,
|
||||
lng: 2,
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('permissions.set', entry('permissions.set'), [
|
||||
'action=grant',
|
||||
'camera',
|
||||
'com.example.app',
|
||||
])
|
||||
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
|
||||
});
|
||||
|
||||
it('accepts named values in any order', () => {
|
||||
expect(
|
||||
buildHostActionParams('permissions.list', entry('permissions.list'), [
|
||||
'bundleId=com.example.app',
|
||||
])
|
||||
).toEqual({ bundleId: 'com.example.app' });
|
||||
expect(
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), [
|
||||
'target=./photo.jpg',
|
||||
'source=file',
|
||||
])
|
||||
).toEqual({ source: 'file', target: './photo.jpg' });
|
||||
});
|
||||
|
||||
it('follows the value order the catalog gives for actions with several shapes', () => {
|
||||
expect(buildHostActionParams('app.install', entry('app.install'), ['./MyApp.ipa'])).toEqual({
|
||||
path: './MyApp.ipa',
|
||||
});
|
||||
expect(
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './photo.jpg'])
|
||||
).toEqual({ source: 'file', target: './photo.jpg' });
|
||||
expect(
|
||||
buildHostActionParams('camera.inject', entry('camera.inject'), [
|
||||
'com.example.app',
|
||||
'off',
|
||||
'placeholder',
|
||||
])
|
||||
).toEqual({ bundleId: 'com.example.app', mirror: 'off', source: 'placeholder' });
|
||||
});
|
||||
|
||||
it('refuses extra values and a value given twice', () => {
|
||||
expect(() =>
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['on', 'off'])
|
||||
).toThrow(
|
||||
'Too many values for hardwareKeyboard.set. Usage: eas simulator:control hardwareKeyboard.set <value>.'
|
||||
);
|
||||
expect(() =>
|
||||
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './a.jpg', './b.jpg'])
|
||||
).toThrow('Too many values for camera.switch.');
|
||||
expect(() =>
|
||||
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), [
|
||||
'value=on',
|
||||
'value=off',
|
||||
])
|
||||
).toThrow('value is given twice for hardwareKeyboard.set.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatHostActionUsage, () => {
|
||||
it.each(['anyOf', 'oneOf'])('shows a %s input group only once', composition => {
|
||||
expect(
|
||||
formatHostActionUsage('app.install', {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bundleId: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
uploadId: { type: 'string' },
|
||||
},
|
||||
required: ['bundleId'],
|
||||
args: ['bundleId', 'path', 'uploadId'],
|
||||
[composition]: [{ required: ['path'] }, { required: ['uploadId'] }],
|
||||
})
|
||||
).toBe('app.install <bundleId> <path|uploadId=...>');
|
||||
});
|
||||
|
||||
it('shows the required alternative file sources for an upload action', () => {
|
||||
expect(formatHostActionUsage('app.install', entry('app.install'))).toBe(
|
||||
'app.install <uploadId=...|path>'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks required and optional parameters', () => {
|
||||
expect(formatHostActionUsage('permissions.set', entry('permissions.set'))).toBe(
|
||||
'permissions.set <action> <service> <bundleId>'
|
||||
);
|
||||
expect(formatHostActionUsage('screenshot.capture', entry('screenshot.capture'))).toBe(
|
||||
'screenshot.capture [fileName]'
|
||||
);
|
||||
expect(formatHostActionUsage('camera.switch', entry('camera.switch'))).toBe(
|
||||
'camera.switch <source> [target]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action transport policy', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
'http://sim.example.test',
|
||||
'http://127.0.0.1.example.test',
|
||||
'http://localhost.example.test',
|
||||
'http://[::ffff:192.0.2.1]',
|
||||
'ftp://127.0.0.1',
|
||||
'ws://127.0.0.1',
|
||||
])('rejects %s before sending the session token', async apiUrl => {
|
||||
const fetch = jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockRejectedValue(new Error('Unexpected fetch'));
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'Simulator actions require HTTPS'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://sim.example.test', 'https://sim.example.test/api'],
|
||||
['http://localhost:44370', 'http://localhost:44370/api'],
|
||||
['http://127.0.0.1:44370', 'http://127.0.0.1:44370/api'],
|
||||
['http://127.255.255.254:44370', 'http://127.255.255.254:44370/api'],
|
||||
['http://2130706433:44370', 'http://127.0.0.1:44370/api'],
|
||||
['http://[::1]:44370', 'http://[::1]:44370/api'],
|
||||
])('accepts %s without following authenticated redirects', async (apiUrl, endpoint) => {
|
||||
const stopped = new Error('Fetch reached');
|
||||
const fetch = jest.spyOn(fetchModule, 'default').mockRejectedValue(stopped);
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toBe(stopped);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
endpoint,
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
redirect: 'error',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action connection', () => {
|
||||
let server: http.Server;
|
||||
let wss: WebSocketServer;
|
||||
let apiUrl: string;
|
||||
let received: { id: number; action: string; params: Record<string, unknown> }[];
|
||||
let requests: (string | undefined)[];
|
||||
let apiBody: string;
|
||||
let uploads: Map<string, Buffer>;
|
||||
let reply: (message: {
|
||||
id: number;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}) => Record<string, unknown> | null;
|
||||
let tmpDir: string;
|
||||
let connection: HostActionConnection | undefined;
|
||||
let closeBeforeReady: boolean;
|
||||
|
||||
function serveSim(message: {
|
||||
id: number;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const ok = (stdout: string): Record<string, unknown> => ({
|
||||
id: message.id,
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
switch (message.action) {
|
||||
case 'actions.describe':
|
||||
return ok(JSON.stringify(catalog));
|
||||
case 'upload.append': {
|
||||
const uploadId = String(message.params.uploadId);
|
||||
const chunk = Buffer.from(String(message.params.data), 'base64');
|
||||
const earlier = message.params.first ? [] : [uploads.get(uploadId) ?? Buffer.alloc(0)];
|
||||
uploads.set(uploadId, Buffer.concat([...earlier, chunk]));
|
||||
return ok(`/tmp/serve-sim-uploads/${uploadId}\n`);
|
||||
}
|
||||
case 'file.readBase64':
|
||||
return ok(Buffer.from('png bytes').toString('base64'));
|
||||
default:
|
||||
return ok('on\n');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
received = [];
|
||||
requests = [];
|
||||
uploads = new Map();
|
||||
apiBody = JSON.stringify({ device: UDID });
|
||||
reply = serveSim;
|
||||
closeBeforeReady = false;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
return;
|
||||
}
|
||||
res.writeHead(401);
|
||||
res.end();
|
||||
});
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: offered =>
|
||||
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
|
||||
});
|
||||
wss.on('connection', ws => {
|
||||
if (closeBeforeReady) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
ws.on('message', data => {
|
||||
const message = JSON.parse(data.toString());
|
||||
received.push(message);
|
||||
const response = reply(message);
|
||||
if (response) {
|
||||
ws.send(JSON.stringify(response));
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connection?.close();
|
||||
connection = undefined;
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
wss.close();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
async function connectAsync(timeoutMs?: number): Promise<HostActionConnection> {
|
||||
connection = await connectHostActionsAsync({ apiUrl, token: TOKEN }, { timeoutMs });
|
||||
return connection;
|
||||
}
|
||||
|
||||
it('sends several requests over one connection with the simulator udid', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const results = await Promise.all([
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
hostActions.requestAsync('hardwareKeyboard.set', { value: 'off' }),
|
||||
]);
|
||||
|
||||
expect(received).toEqual([
|
||||
{ id: 1, action: 'hardwareKeyboard.get', params: { udid: UDID } },
|
||||
{ id: 2, action: 'hardwareKeyboard.set', params: { value: 'off', udid: UDID } },
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ stdout: 'on\n', stderr: '', exitCode: 0 },
|
||||
{ stdout: 'on\n', stderr: '', exitCode: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the catalog of actions', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(fetchHostActionCatalogAsync(hostActions)).resolves.toEqual(catalog);
|
||||
});
|
||||
|
||||
it('explains that an old serve-sim does not have the catalog', async () => {
|
||||
reply = message => ({ id: message.id, error: 'unknown action actions.describe' });
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(fetchHostActionCatalogAsync(hostActions)).rejects.toThrow(
|
||||
'does not support this action'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a validation error from the simulator through', async () => {
|
||||
reply = message => ({
|
||||
id: message.id,
|
||||
error: 'hardwareKeyboard.set: value must be one of on, off',
|
||||
});
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
hostActions.requestAsync('hardwareKeyboard.set', { value: 'maybe' })
|
||||
).rejects.toThrow(
|
||||
'The simulator refused the request: hardwareKeyboard.set: value must be one of on, off'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails a request at once on a reply it cannot read', async () => {
|
||||
reply = message => ({ id: message.id, output: 'on' });
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
|
||||
'sent a reply that this version of eas-cli cannot read'
|
||||
);
|
||||
});
|
||||
|
||||
it('gives up on a request the simulator does not answer', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync(100);
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
|
||||
'The simulator did not answer within'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a path on the API URL and drops its query', async () => {
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
|
||||
).rejects.toThrow('refused the session token (HTTP 401)');
|
||||
expect(requests).toEqual(['/preview/api']);
|
||||
});
|
||||
|
||||
it('refuses a wrong token before it opens the socket', async () => {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: 'wrong' })).rejects.toThrow(
|
||||
'refused the session token (HTTP 401)'
|
||||
);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not send the session token to an API redirect target', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api') {
|
||||
res.writeHead(302, { Location: '/redirected-api' });
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(connectAsync()).rejects.toThrow(/redirect/i);
|
||||
expect(requests).toEqual(['/api']);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('explains a connection that closes during setup', async () => {
|
||||
closeBeforeReady = true;
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
/closed the connection during setup.*token.*eas simulator:get/
|
||||
);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'closes a refused HTTP %i upgrade without waiting for its body',
|
||||
async status => {
|
||||
let rejectedSocket: Duplex | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const closed = new Promise<void>(resolve => {
|
||||
server.removeAllListeners('upgrade');
|
||||
server.once('upgrade', (_req, socket) => {
|
||||
rejectedSocket = socket;
|
||||
socket.once('close', resolve);
|
||||
socket.on('end', () => socket.end());
|
||||
socket.write(
|
||||
`HTTP/1.1 ${status} Refused\r\nConnection: keep-alive\r\nContent-Length: 1000000\r\n\r\nrefused`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
`refused the connection (HTTP ${status})`
|
||||
);
|
||||
await Promise.race([
|
||||
closed,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error('Refused upgrade socket stayed open'));
|
||||
}, 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
rejectedSocket?.destroy();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('explains a preview server that does not report its simulator', async () => {
|
||||
apiBody = '<html>starting</html>';
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'did not report which simulator it streams'
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a file in order and returns where the simulator host stored it', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'hello simulator');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const upload = await uploadFileAsync(hostActions, localFile, { chunkBytes: 4 });
|
||||
|
||||
expect(upload.uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.ipa$/);
|
||||
expect(upload.path).toBe(`/tmp/serve-sim-uploads/${upload.uploadId}`);
|
||||
expect(uploads.get(upload.uploadId)?.toString()).toBe('hello simulator');
|
||||
expect(received.map(message => message.params.first)).toEqual([true, false, false, false]);
|
||||
});
|
||||
|
||||
it('removes a partial upload when a chunk fails', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'hello simulator');
|
||||
reply = message =>
|
||||
message.action === 'upload.append' && !message.params.first
|
||||
? { id: message.id, stdout: '', stderr: 'The upload staging area is full.', exitCode: 1 }
|
||||
: serveSim(message);
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(uploadFileAsync(hostActions, localFile, { chunkBytes: 4 })).rejects.toThrow(
|
||||
`Could not upload ${localFile}: The upload staging area is full.`
|
||||
);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
action: 'upload.remove',
|
||||
params: { uploadId: received[0].params.uploadId },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to upload an empty file', async () => {
|
||||
const localFile = path.join(tmpDir, 'empty.png');
|
||||
await fs.writeFile(localFile, '');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(uploadFileAsync(hostActions, localFile)).rejects.toThrow(`${localFile} is empty.`);
|
||||
});
|
||||
|
||||
it('downloads a file the action left on the simulator host', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
|
||||
const saved = await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
outputPath
|
||||
);
|
||||
|
||||
expect(saved).toBe(outputPath);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
action: 'file.readBase64',
|
||||
params: { path: '/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png' },
|
||||
});
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
|
||||
});
|
||||
|
||||
it('saves into a folder under the name the host gave the file', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
const saved = await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
tmpDir
|
||||
);
|
||||
|
||||
expect(saved).toBe(path.join(tmpDir, 'serve-sim-screenshot-1.png'));
|
||||
expect(await fs.readFile(path.join(tmpDir, 'serve-sim-screenshot-1.png'), 'utf8')).toBe(
|
||||
'png bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not replace a file it named itself', async () => {
|
||||
const existing = path.join(tmpDir, 'serve-sim-screenshot-1.png');
|
||||
await fs.writeFile(existing, 'mine');
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('screenshot.capture'),
|
||||
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
|
||||
tmpDir
|
||||
)
|
||||
).rejects.toThrow(`${existing} already exists.`);
|
||||
expect(await fs.readFile(existing, 'utf8')).toBe('mine');
|
||||
});
|
||||
|
||||
it('saves file contents the action returned', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const outputPath = path.join(tmpDir, 'Info.plist');
|
||||
|
||||
await saveHostActionOutputAsync(
|
||||
hostActions,
|
||||
entry('file.readBase64'),
|
||||
Buffer.from('plist').toString('base64'),
|
||||
outputPath
|
||||
);
|
||||
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('plist');
|
||||
});
|
||||
|
||||
it('saves nothing for an action that returns no file', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
|
||||
await expect(
|
||||
saveHostActionOutputAsync(hostActions, entry('hardwareKeyboard.set'), 'on', undefined)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(assertHostActionOutputAsync, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-action-output-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('allows --output only for actions that return a file', async () => {
|
||||
await expect(
|
||||
assertHostActionOutputAsync('app.container', entry('app.container'), './out')
|
||||
).rejects.toThrow('app.container does not return a file, so --output does not apply.');
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), undefined)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), tmpDir)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('needs a file path for an action that returns file contents', async () => {
|
||||
await expect(
|
||||
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), undefined)
|
||||
).rejects.toThrow('file.readBase64 returns a file. Pass --output with the path to save it to.');
|
||||
await expect(
|
||||
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), tmpDir)
|
||||
).rejects.toThrow(`${tmpDir} is a folder. Pass --output with a file path for file.readBase64.`);
|
||||
});
|
||||
|
||||
it('refuses a path in a folder that does not exist', async () => {
|
||||
const missing = path.join(tmpDir, 'missing', 'shot.png');
|
||||
|
||||
await expect(
|
||||
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), missing)
|
||||
).rejects.toThrow(`The folder for ${missing} does not exist.`);
|
||||
await expect(
|
||||
assertHostActionOutputAsync(
|
||||
'screenshot.capture',
|
||||
entry('screenshot.capture'),
|
||||
path.join(tmpDir, 'missing') + path.sep
|
||||
)
|
||||
).rejects.toThrow('does not exist');
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { AddressInfo } from 'net';
|
||||
import nock from 'nock';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import {
|
||||
UnsupportedServeSimHostActionError,
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../serveSimHost';
|
||||
import { DeviceRunSessionRemoteConfig } from '../utils';
|
||||
|
||||
const TOKEN = 'preview-token';
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
|
||||
describe(getServeSimHost, () => {
|
||||
it('reads the web preview host of a controller session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('reads the preview host of a serve-sim session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
previewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('returns null before the session reports its preview host', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
previewApiUrl: null,
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getServeSimDeviceUdidAsync, () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('reads the device from the preview state with the session token', async () => {
|
||||
nock('https://preview.example.com', {
|
||||
reqheaders: { authorization: `Bearer ${TOKEN}` },
|
||||
})
|
||||
.get('/api')
|
||||
.reply(200, { device: UDID, port: 3200 });
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).resolves.toBe(UDID);
|
||||
});
|
||||
|
||||
it('fails when no simulator is running', async () => {
|
||||
nock('https://preview.example.com').get('/api').reply(200, 'null');
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).rejects.toThrow('no running simulator');
|
||||
});
|
||||
});
|
||||
|
||||
describe(runServeSimHostActionAsync, () => {
|
||||
let server: WebSocketServer;
|
||||
let apiUrl: string;
|
||||
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
|
||||
let protocols: string[];
|
||||
let requests: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
protocols = [];
|
||||
requests = [];
|
||||
server = new WebSocketServer({
|
||||
port: 0,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: offered => {
|
||||
protocols = [...offered];
|
||||
return offered.values().next().value ?? false;
|
||||
},
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', data => {
|
||||
const request = JSON.parse(data.toString());
|
||||
requests.push(request);
|
||||
socket.send(JSON.stringify(reply(request)));
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
server.once('listening', resolve);
|
||||
});
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs one action over the token-authenticated exec channel', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).resolves.toEqual({ stdout: '', stderr: '' });
|
||||
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
|
||||
expect(requests).toEqual([
|
||||
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a rejected session token', async () => {
|
||||
server.removeAllListeners('connection');
|
||||
server.on('connection', socket => {
|
||||
socket.close();
|
||||
});
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('rejected the session token');
|
||||
});
|
||||
|
||||
it('reports an action the session does not have yet', async () => {
|
||||
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
|
||||
).rejects.toThrow(UnsupportedServeSimHostActionError);
|
||||
});
|
||||
|
||||
it('reports a failed command with its stderr', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('appearance.set failed: Invalid device');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { resolveSimctlHostAction } from '../simctl';
|
||||
|
||||
describe(resolveSimctlHostAction, () => {
|
||||
it('maps the appearance command', () => {
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'dark' },
|
||||
});
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'light' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps the SpringBoard restart', () => {
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
])
|
||||
).toEqual({ action: 'springboard.restart', params: {} });
|
||||
});
|
||||
|
||||
it('rejects every other command', () => {
|
||||
expect(resolveSimctlHostAction([])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.backboardd',
|
||||
])
|
||||
).toBeNull();
|
||||
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getLoopbackForwardPlan,
|
||||
getPreviewApiAccess,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../utils';
|
||||
@@ -837,6 +838,45 @@ describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(getPreviewApiAccess, () => {
|
||||
it('reads the preview token for each remote config type', () => {
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.test',
|
||||
agentDeviceRemoteSessionToken: 'agent-token',
|
||||
webPreviewToken: 'preview-token',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
|
||||
});
|
||||
|
||||
it('returns null without an API URL or a token', () => {
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewToken: 'preview-token',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
getPreviewApiAccess({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://sim.example.test',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatEmbedPreviewUrl, () => {
|
||||
it('is null until the worker reports the preview host', () => {
|
||||
expect(formatEmbedPreviewUrl(agentDeviceConfig)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PreviewApiAccess } from './utils';
|
||||
import fetch, { RequestError, httpsProxyAgent } from '../fetch';
|
||||
|
||||
export type HostActionResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
export type HostActionConnection = {
|
||||
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
const SchemaNode = z.looseObject({
|
||||
type: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
allOf: z.array(z.unknown()).optional(),
|
||||
anyOf: z.array(z.unknown()).optional(),
|
||||
oneOf: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const CatalogEntry = SchemaNode.extend({
|
||||
description: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
upload: z.string().optional(),
|
||||
uploadArchive: z.string().optional(),
|
||||
uploadAs: z.enum(['path', 'id']).optional(),
|
||||
uploadWhen: z.record(z.string(), z.unknown()).optional(),
|
||||
keepUpload: z.boolean().optional(),
|
||||
output: z.enum(['path', 'base64']).optional(),
|
||||
});
|
||||
|
||||
const Catalog = z.record(z.string(), CatalogEntry);
|
||||
|
||||
export type HostActionCatalog = z.infer<typeof Catalog>;
|
||||
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
|
||||
|
||||
const PreviewConfig = z.object({ device: z.string().min(1) });
|
||||
|
||||
const ReplyId = z.object({ id: z.number() });
|
||||
|
||||
const SocketMessage = z.union([
|
||||
z.object({ ready: z.literal(true) }),
|
||||
z.object({ id: z.number(), error: z.string() }),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
exitCode: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const HOST_ACTION_TIMEOUT_MS = 130_000;
|
||||
const PREVIEW_API_TIMEOUT_MS = 30_000;
|
||||
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
|
||||
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
|
||||
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
||||
|
||||
function previewEndpoint(apiUrl: string, endpoint: string): URL {
|
||||
const base = new URL(apiUrl);
|
||||
const loopback =
|
||||
base.hostname === 'localhost' ||
|
||||
base.hostname === '[::1]' ||
|
||||
/^127(?:\.\d{1,3}){3}$/.test(base.hostname);
|
||||
if (base.protocol !== 'https:' && (base.protocol !== 'http:' || !loopback)) {
|
||||
throw new Error(
|
||||
'Simulator actions require HTTPS for remote preview servers. HTTP is supported only on localhost or a loopback address. Start a new session with `eas simulator:start`.'
|
||||
);
|
||||
}
|
||||
base.search = '';
|
||||
base.hash = '';
|
||||
if (!base.pathname.endsWith('/')) {
|
||||
base.pathname += '/';
|
||||
}
|
||||
return new URL(endpoint, base);
|
||||
}
|
||||
|
||||
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
|
||||
let text: string;
|
||||
try {
|
||||
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
|
||||
throw new Error(
|
||||
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`The simulator preview server did not answer within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
const config = PreviewConfig.safeParse(body);
|
||||
if (!config.success) {
|
||||
throw new Error(
|
||||
'The simulator preview server did not report which simulator it streams. The session may still be starting. Wait a few seconds and run the command again.'
|
||||
);
|
||||
}
|
||||
return config.data.device;
|
||||
}
|
||||
|
||||
function refusedError(error: string): Error {
|
||||
return error.startsWith('unknown action')
|
||||
? new Error(
|
||||
'This simulator session runs a serve-sim version that does not support this action. Start a new session with `eas simulator:start` to get a newer version.'
|
||||
)
|
||||
: new Error(`The simulator refused the request: ${error}`);
|
||||
}
|
||||
|
||||
export async function connectHostActionsAsync(
|
||||
access: PreviewApiAccess,
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
): Promise<HostActionConnection> {
|
||||
const udid = await fetchSimulatorUdidAsync(access);
|
||||
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
|
||||
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(
|
||||
socketUrl.toString(),
|
||||
[`serve-sim.token.${access.token}`],
|
||||
httpsProxyAgent ? { agent: httpsProxyAgent } : {}
|
||||
);
|
||||
|
||||
type Pending = {
|
||||
resolve: (result: HostActionResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
const pending = new Map<number, Pending>();
|
||||
let nextId = 1;
|
||||
let lost: Error | null = null;
|
||||
let ready = false;
|
||||
|
||||
function settle(id: number): Pending | undefined {
|
||||
const request = pending.get(id);
|
||||
if (request) {
|
||||
pending.delete(id);
|
||||
clearTimeout(request.timer);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function loseConnection(error: Error): void {
|
||||
lost ??= error;
|
||||
for (const id of [...pending.keys()]) {
|
||||
settle(id)?.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
|
||||
)
|
||||
);
|
||||
ws.terminate();
|
||||
}, PREVIEW_API_TIMEOUT_MS);
|
||||
const fail = (error: Error): void => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
loseConnection(error);
|
||||
if (!ready) {
|
||||
ws.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
ws.on('message', data => {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const parsed = SocketMessage.safeParse(message);
|
||||
if (!parsed.success) {
|
||||
const id = ReplyId.safeParse(message);
|
||||
if (id.success) {
|
||||
settle(id.data.id)?.reject(
|
||||
new Error(
|
||||
'The simulator preview server sent a reply that this version of eas-cli cannot read. Update eas-cli and try again.'
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reply = parsed.data;
|
||||
if ('ready' in reply) {
|
||||
ready = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} else if ('error' in reply) {
|
||||
settle(reply.id)?.reject(refusedError(reply.error));
|
||||
} else {
|
||||
settle(reply.id)?.resolve({
|
||||
stdout: reply.stdout,
|
||||
stderr: reply.stderr,
|
||||
exitCode: reply.exitCode,
|
||||
});
|
||||
}
|
||||
});
|
||||
ws.on('unexpected-response', (_req, res) => {
|
||||
res.destroy();
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
)
|
||||
);
|
||||
});
|
||||
ws.on('error', error => {
|
||||
fail(
|
||||
new Error(
|
||||
`Could not connect to the simulator preview server: ${error.message}. Check your network connection and that the session still runs.`
|
||||
)
|
||||
);
|
||||
});
|
||||
ws.on('close', () => {
|
||||
fail(
|
||||
new Error(
|
||||
ready
|
||||
? 'The simulator preview server closed the connection before it answered. The action may still have run. Check the current state before you try again.'
|
||||
: 'The simulator preview server closed the connection during setup. The session token may be invalid or expired, or the session may run an older serve-sim version. Check the session with `eas simulator:get` before starting a new session.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async requestAsync(action, params = {}) {
|
||||
if (lost) {
|
||||
throw lost;
|
||||
}
|
||||
const id = nextId++;
|
||||
return await new Promise<HostActionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(id)?.reject(
|
||||
new Error(
|
||||
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
|
||||
});
|
||||
},
|
||||
close() {
|
||||
ws.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchHostActionCatalogAsync(
|
||||
connection: HostActionConnection
|
||||
): Promise<HostActionCatalog> {
|
||||
const result = await connection.requestAsync('actions.describe');
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(result.stdout);
|
||||
} catch {}
|
||||
const catalog = Catalog.safeParse(body);
|
||||
if (result.exitCode !== 0 || !catalog.success) {
|
||||
throw new Error(
|
||||
'The simulator did not return its list of actions. Start a new session with `eas simulator:start` to get a newer serve-sim version.'
|
||||
);
|
||||
}
|
||||
return catalog.data;
|
||||
}
|
||||
|
||||
function collectProperties(node: unknown, into: Map<string, unknown[]>): void {
|
||||
const parsed = SchemaNode.safeParse(node);
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
for (const [name, schema] of Object.entries(parsed.data.properties ?? {})) {
|
||||
into.set(name, [...(into.get(name) ?? []), schema]);
|
||||
}
|
||||
const { allOf = [], anyOf = [], oneOf = [] } = parsed.data;
|
||||
for (const child of [...allOf, ...anyOf, ...oneOf]) {
|
||||
collectProperties(child, into);
|
||||
}
|
||||
}
|
||||
|
||||
function hostActionProperties(entry: HostActionCatalogEntry): Map<string, unknown[]> {
|
||||
const properties = new Map<string, unknown[]>();
|
||||
collectProperties(entry, properties);
|
||||
properties.delete('udid');
|
||||
return properties;
|
||||
}
|
||||
|
||||
function positionalNames(entry: HostActionCatalogEntry): string[] {
|
||||
return entry.args ?? Object.keys(entry.properties ?? {}).filter(name => name !== 'udid');
|
||||
}
|
||||
|
||||
function requiredProperties(node: unknown): Set<string> {
|
||||
const parsed = SchemaNode.safeParse(node);
|
||||
if (!parsed.success) {
|
||||
return new Set();
|
||||
}
|
||||
const required = new Set(parsed.data.required ?? []);
|
||||
for (const child of parsed.data.allOf ?? []) {
|
||||
requiredProperties(child).forEach(name => required.add(name));
|
||||
}
|
||||
const branches = [...(parsed.data.anyOf ?? []), ...(parsed.data.oneOf ?? [])].map(
|
||||
requiredProperties
|
||||
);
|
||||
for (const name of branches[0] ?? []) {
|
||||
if (branches.every(branch => branch.has(name))) {
|
||||
required.add(name);
|
||||
}
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
function acceptsType(schema: unknown, types: string[]): boolean {
|
||||
const node = SchemaNode.safeParse(schema);
|
||||
if (!node.success) {
|
||||
return false;
|
||||
}
|
||||
const own = [node.data.type ?? []].flat();
|
||||
return (
|
||||
own.some(type => types.includes(type)) ||
|
||||
[...(node.data.anyOf ?? []), ...(node.data.oneOf ?? [])].some(child =>
|
||||
acceptsType(child, types)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function coerce(value: string, schemas: unknown[]): unknown {
|
||||
if (/^[[{]/.test(value) && schemas.some(schema => acceptsType(schema, ['array', 'object']))) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (NUMBER.test(value) && schemas.some(schema => acceptsType(schema, ['number', 'integer']))) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
(value === 'true' || value === 'false') &&
|
||||
schemas.some(schema => acceptsType(schema, ['boolean']))
|
||||
) {
|
||||
return value === 'true';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatHostActionUsage(name: string, entry: HostActionCatalogEntry): string {
|
||||
const required = requiredProperties(entry);
|
||||
const alternatives = (entry.anyOf ?? entry.oneOf ?? []).map(branch =>
|
||||
[...requiredProperties(branch)].filter(
|
||||
property => property !== 'udid' && !required.has(property)
|
||||
)
|
||||
);
|
||||
const requiredAlternatives =
|
||||
alternatives.length > 0 && alternatives.every(properties => properties.length === 1)
|
||||
? alternatives.map(([property]) => property)
|
||||
: [];
|
||||
let describedAlternatives = false;
|
||||
const names = positionalNames(entry).flatMap(property => {
|
||||
if (requiredAlternatives.includes(property)) {
|
||||
if (describedAlternatives) {
|
||||
return [];
|
||||
}
|
||||
describedAlternatives = true;
|
||||
return [
|
||||
`<${requiredAlternatives.map(name => (name === property ? name : `${name}=...`)).join('|')}>`,
|
||||
];
|
||||
}
|
||||
return [required.has(property) ? `<${property}>` : `[${property}]`];
|
||||
});
|
||||
return [name, ...names].join(' ');
|
||||
}
|
||||
|
||||
export function buildHostActionParams(
|
||||
name: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
values: string[],
|
||||
literalFrom = values.length
|
||||
): Record<string, unknown> {
|
||||
const properties = hostActionProperties(entry);
|
||||
const params: Record<string, unknown> = {};
|
||||
const positional: string[] = [];
|
||||
for (const [index, value] of values.entries()) {
|
||||
const named = index < literalFrom ? NAMED_VALUE.exec(value) : null;
|
||||
if (!named || !properties.has(named[1])) {
|
||||
positional.push(value);
|
||||
} else if (Object.hasOwn(params, named[1])) {
|
||||
throw new Error(`${named[1]} is given twice for ${name}.`);
|
||||
} else {
|
||||
params[named[1]] = coerce(named[2], properties.get(named[1]) ?? []);
|
||||
}
|
||||
}
|
||||
const open = positionalNames(entry).filter(property => !Object.hasOwn(params, property));
|
||||
if (positional.length > open.length) {
|
||||
throw new Error(
|
||||
`Too many values for ${name}. Usage: eas simulator:control ${formatHostActionUsage(name, entry)}. Name a value with name=value.`
|
||||
);
|
||||
}
|
||||
positional.forEach((value, index) => {
|
||||
params[open[index]] = coerce(value, properties.get(open[index]) ?? []);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function assertHostActionOutputAsync(
|
||||
name: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
outputPath: string | undefined
|
||||
): Promise<void> {
|
||||
if (!outputPath) {
|
||||
if (entry.output === 'base64') {
|
||||
throw new Error(`${name} returns a file. Pass --output with the path to save it to.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!entry.output) {
|
||||
throw new Error(`${name} does not return a file, so --output does not apply.`);
|
||||
}
|
||||
if ((await fs.stat(outputPath).catch(() => null))?.isDirectory()) {
|
||||
if (entry.output === 'base64') {
|
||||
throw new Error(`${outputPath} is a folder. Pass --output with a file path for ${name}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const folder = await fs.stat(path.dirname(outputPath)).catch(() => null);
|
||||
if (outputPath.endsWith('/') || outputPath.endsWith(path.sep) || !folder?.isDirectory()) {
|
||||
throw new Error(
|
||||
`The folder for ${outputPath} does not exist. Create it, or pass --output with a path in a folder that exists.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFileAsync(
|
||||
connection: HostActionConnection,
|
||||
localPath: string,
|
||||
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
|
||||
): Promise<{ uploadId: string; path: string }> {
|
||||
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
let remotePath = '';
|
||||
let first = true;
|
||||
try {
|
||||
for await (const chunk of fs.createReadStream(localPath, { highWaterMark: chunkBytes })) {
|
||||
const result = await connection.requestAsync('upload.append', {
|
||||
uploadId,
|
||||
data: Buffer.from(chunk).toString('base64'),
|
||||
first,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Could not upload ${localPath}: ${result.stderr.trim()}`);
|
||||
}
|
||||
remotePath = result.stdout.trim();
|
||||
first = false;
|
||||
}
|
||||
} catch (error) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (first) {
|
||||
throw new Error(`${localPath} is empty. Pass a file with content.`);
|
||||
}
|
||||
return { uploadId, path: remotePath };
|
||||
}
|
||||
|
||||
export async function saveHostActionOutputAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
stdout: string,
|
||||
outputPath: string | undefined
|
||||
): Promise<string | null> {
|
||||
if (!entry.output) {
|
||||
return null;
|
||||
}
|
||||
let base64 = stdout;
|
||||
let defaultName: string | undefined;
|
||||
if (entry.output === 'path') {
|
||||
const remotePath = stdout.trim();
|
||||
const file = await connection.requestAsync('file.readBase64', { path: remotePath });
|
||||
if (file.exitCode !== 0) {
|
||||
throw new Error(`Could not download ${remotePath}: ${file.stderr.trim()}`);
|
||||
}
|
||||
base64 = file.stdout;
|
||||
defaultName = path.basename(remotePath);
|
||||
}
|
||||
const outputIsFolder = outputPath
|
||||
? (await fs.stat(outputPath).catch(() => null))?.isDirectory()
|
||||
: false;
|
||||
const generatedName = outputIsFolder || !outputPath ? defaultName : undefined;
|
||||
const target = generatedName ? path.join(outputPath ?? '.', generatedName) : outputPath;
|
||||
if (!target) {
|
||||
throw new Error('Pass --output with a file path to save the file.');
|
||||
}
|
||||
try {
|
||||
await fs.writeFile(target, Buffer.from(base64, 'base64'), {
|
||||
flag: target === outputPath ? 'w' : 'wx',
|
||||
});
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
|
||||
throw new Error(`${target} already exists. Pass --output with a new path to save the file.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return path.resolve(target);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import fetch, { httpsProxyAgent } from '../fetch';
|
||||
import { DeviceRunSessionRemoteConfig } from './utils';
|
||||
|
||||
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type ServeSimHost = {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ServeSimHostActionResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export class UnsupportedServeSimHostActionError extends Error {}
|
||||
|
||||
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.previewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
|
||||
: null;
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
case 'AppiumRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
|
||||
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${host.token}` },
|
||||
});
|
||||
const state = (await response.json()) as { device?: unknown } | null;
|
||||
if (typeof state?.device !== 'string' || state.device.length === 0) {
|
||||
throw new Error('The simulator session has no running simulator.');
|
||||
}
|
||||
return state.device;
|
||||
}
|
||||
|
||||
export async function runServeSimHostActionAsync(
|
||||
host: ServeSimHost,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
): Promise<ServeSimHostActionResult> {
|
||||
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
|
||||
const socket = new WebSocket(
|
||||
url.toString(),
|
||||
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
|
||||
{
|
||||
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
|
||||
}
|
||||
);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.terminate();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result!);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
|
||||
}, timeoutMs);
|
||||
socket.on('error', error => {
|
||||
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
|
||||
});
|
||||
socket.on('close', () => {
|
||||
finish(
|
||||
new Error(
|
||||
sent
|
||||
? `The simulator host closed the connection before ${action} finished.`
|
||||
: 'The simulator host rejected the session token.'
|
||||
)
|
||||
);
|
||||
});
|
||||
socket.on('message', data => {
|
||||
let reply: any;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
} catch {
|
||||
finish(new Error('The simulator host sent an invalid response.'));
|
||||
return;
|
||||
}
|
||||
if (reply?.ready === true && !sent) {
|
||||
sent = true;
|
||||
socket.send(JSON.stringify({ id: 1, action, params }));
|
||||
return;
|
||||
}
|
||||
if (reply?.id !== 1 || !sent) {
|
||||
return;
|
||||
}
|
||||
if (typeof reply.error === 'string') {
|
||||
finish(
|
||||
reply.error === `unknown action ${action}`
|
||||
? new UnsupportedServeSimHostActionError(
|
||||
`This simulator session does not support ${action} yet. Start a new session to use it.`
|
||||
)
|
||||
: new Error(`${action} failed: ${reply.error}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
|
||||
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
|
||||
if (reply.exitCode !== 0) {
|
||||
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
|
||||
return;
|
||||
}
|
||||
finish(null, { stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServeSimUrl(apiUrl: string, route: string): URL {
|
||||
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export type SimctlHostAction = {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SPRINGBOARD_RESTART_ARGS = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
export const SUPPORTED_SIMCTL_COMMANDS = [
|
||||
'ui appearance <light|dark>',
|
||||
SPRINGBOARD_RESTART_ARGS.join(' '),
|
||||
];
|
||||
|
||||
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
|
||||
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
|
||||
const value = args[2];
|
||||
if (value === 'light' || value === 'dark') {
|
||||
return { action: 'appearance.set', params: { value } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
args.length === SPRINGBOARD_RESTART_ARGS.length &&
|
||||
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
|
||||
) {
|
||||
return { action: 'springboard.restart', params: {} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -142,6 +142,25 @@ export function getLocalEgressConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export type PreviewApiAccess = {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function getPreviewApiAccess(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): PreviewApiAccess | null {
|
||||
const token =
|
||||
remoteConfig.__typename === 'ServeSimRunSessionRemoteConfig' ||
|
||||
remoteConfig.__typename === 'WebPreviewOnlyRunSessionRemoteConfig'
|
||||
? remoteConfig.previewToken
|
||||
: remoteConfig.webPreviewToken;
|
||||
if (!remoteConfig.previewApiUrl || !token) {
|
||||
return null;
|
||||
}
|
||||
return { apiUrl: remoteConfig.previewApiUrl, token };
|
||||
}
|
||||
|
||||
/** The worker relay that the Android emulator's proxy points at; see build-tools androidLocalEgress.ts. */
|
||||
export const ANDROID_EMULATOR_EGRESS_RELAY_PORT = 8898;
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "25.1.1",
|
||||
"@expo/build-tools": "25.2.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "25.1.1",
|
||||
"version": "25.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "25.1.1",
|
||||
"@expo/build-tools": "25.2.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/eas-build-job": "25.2.0",
|
||||
"@expo/logger": "25.2.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/steps": "25.2.0",
|
||||
"@expo/template-file": "25.2.0",
|
||||
"@expo/turtle-spawn": "25.2.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:25.1.1, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:25.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/downloader": "npm:25.2.0"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.1.1"
|
||||
"@expo/template-file": "npm:25.1.1"
|
||||
"@expo/turtle-spawn": "npm:25.1.1"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/template-file": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:25.1.1, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:25.2.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:25.1.1, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:25.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:25.1.1"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:25.1.1, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:25.2.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:25.1.1, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:25.2.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3131,12 +3131,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:25.1.1, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:25.2.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3162,7 +3162,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:25.1.1, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:25.2.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3185,11 +3185,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:25.1.1, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:25.2.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3205,17 +3205,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:25.1.1"
|
||||
"@expo/build-tools": "npm:25.2.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.1.1"
|
||||
"@expo/template-file": "npm:25.1.1"
|
||||
"@expo/turtle-spawn": "npm:25.1.1"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/template-file": "npm:25.2.0"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10099,7 +10099,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:25.1.1"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10683,12 +10683,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:25.1.1"
|
||||
"@expo/build-tools": "npm:25.2.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:25.1.1"
|
||||
"@expo/turtle-spawn": "npm:25.2.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10742,12 +10742,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:25.1.1"
|
||||
"@expo/eas-json": "npm:25.1.1"
|
||||
"@expo/eas-build-job": "npm:25.2.0"
|
||||
"@expo/eas-json": "npm:25.2.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:25.1.1"
|
||||
"@expo/logger": "npm:25.2.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10759,7 +10759,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:25.1.1"
|
||||
"@expo/steps": "npm:25.2.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user