mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e716de0f | ||
|
|
42fc262d8d | ||
|
|
879a13990b | ||
|
|
6581bb7104 | ||
|
|
7e42b3867d | ||
|
|
5499d03b8f | ||
|
|
03c25a7a29 | ||
|
|
88edfb0729 | ||
|
|
5d8f23e666 | ||
|
|
f77cd548c3 | ||
|
|
0abcbb746d | ||
|
|
7fc29a9f18 | ||
|
|
0a39966a40 | ||
|
|
cf1d77160b |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Import ordered action configurations with `eas simulator:control --import` and show progress for each action.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add searchable interactive simulator controls with session and device details, guided parameters, and action results.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Start simulator sessions from saved settings and apply control actions after readiness.
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import * as fetchModule from '../../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
@@ -13,11 +14,16 @@ import {
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { ora } from '../../../ora';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import * as controlSession from '../../../simulator/controlSession';
|
||||
import { runControlMenuAsync } from '../../../simulator/controlInteractive';
|
||||
import { ControlPromptCancelledError } from '../../../simulator/controlPrompts';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
} from '../../../simulator/hostActions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControl from '../control';
|
||||
@@ -31,6 +37,7 @@ jest.mock('../../../simulator/env', () => ({
|
||||
jest.mock('../../../simulator/hostActions', () => ({
|
||||
...jest.requireActual('../../../simulator/hostActions'),
|
||||
connectHostActionsAsync: jest.fn(),
|
||||
fetchHostActionDeviceAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -48,6 +55,7 @@ jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual<typeof import('fs-extra')>('fs-extra');
|
||||
return { ...actual, stat: jest.fn(actual.stat) };
|
||||
});
|
||||
jest.mock('../../../simulator/controlInteractive');
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
@@ -107,6 +115,7 @@ describe(SimulatorControl, () => {
|
||||
let actionResult: HostActionResult;
|
||||
let served: HostActionCatalog;
|
||||
let tmpDir: string;
|
||||
let connectionController: AbortController;
|
||||
const requestAsync = jest.fn(
|
||||
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
|
||||
switch (action) {
|
||||
@@ -129,7 +138,17 @@ describe(SimulatorControl, () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockResolvedValue({ udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' });
|
||||
connectionController = new AbortController();
|
||||
mockConnectHostActionsAsync.mockResolvedValue({
|
||||
requestAsync,
|
||||
close,
|
||||
udid: 'simulator-udid',
|
||||
signal: connectionController.signal,
|
||||
});
|
||||
jest.mocked(runControlMenuAsync).mockResolvedValue(undefined);
|
||||
actionResult = ok('off\n');
|
||||
served = catalog;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
|
||||
@@ -153,6 +172,194 @@ describe(SimulatorControl, () => {
|
||||
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
|
||||
}
|
||||
|
||||
async function writeImportAsync(actions: unknown[]): Promise<string> {
|
||||
const filePath = path.join(tmpDir, 'control.json');
|
||||
await fs.writeJson(filePath, { version: 1, actions });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it('imports an ordered config without prompts over the existing connection', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
]);
|
||||
await createCommand([
|
||||
'--import',
|
||||
filePath,
|
||||
'--id',
|
||||
'session-123',
|
||||
'--non-interactive',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([
|
||||
['hardwareKeyboard.set', { value: 'off' }],
|
||||
['hardwareKeyboard.set', { value: 'on' }],
|
||||
]);
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('iPhone 17 Pro · iOS 26.4'));
|
||||
expect(Log.log).toHaveBeenCalledWith(' off');
|
||||
const spinner = jest.mocked(ora).mock.results[1].value;
|
||||
expect(spinner.start).toHaveBeenCalledWith('[1/2] hardwareKeyboard.set');
|
||||
expect(spinner.start).toHaveBeenCalledWith('[2/2] hardwareKeyboard.set');
|
||||
expect(Log.succeed).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import complete · 2 actions')
|
||||
);
|
||||
});
|
||||
|
||||
it('prints one JSON result for a successful imported batch', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"enabled":false}\n');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: { enabled: false }, file: null }],
|
||||
});
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
expect(ora).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
expect(Log.succeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains completed results and failure metadata in JSON and stops the imported batch', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first completed'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Permission denied', exitCode: 1 });
|
||||
const previous = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: 'first completed', file: null }],
|
||||
error: {
|
||||
step: 2,
|
||||
action: 'hardwareKeyboard.set',
|
||||
message: expect.stringContaining('Permission denied'),
|
||||
},
|
||||
});
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.exitCode = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('preflights every imported step before running the first action', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'invalid' } },
|
||||
]);
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(ora).mock.results[1].value.fail).toHaveBeenCalledWith(
|
||||
'Validation failed. No actions were run.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: ['hardwareKeyboard.set', 'off'] },
|
||||
{ args: ['--list'] },
|
||||
{ args: ['--output', './out.png'] },
|
||||
])('rejects conflicting import options before connecting: %j', async ({ args }) => {
|
||||
await expect(
|
||||
createCommand(['--import', './control.json', '--id', 'session-123', ...args]).runAsync()
|
||||
).rejects.toThrow('--import cannot be combined');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses Android imports before connecting or reading the config file', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(
|
||||
createCommand(['--import', './missing.json', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('not supported yet');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints completed steps before a non-JSON import failure', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first done'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Native action failed', exitCode: 1 });
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(Log.log).toHaveBeenCalledWith(' first done');
|
||||
expect(Log.log).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(Log.gray).toHaveBeenCalledWith(' [3/3] hardwareKeyboard.set skipped');
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('downloads config-relative outputs and reports each saved file', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'screenshot.capture', output: './shot.png' },
|
||||
]);
|
||||
actionResult = ok('/remote/screenshot.png');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
const savedPath = path.join(tmpDir, 'shot.png');
|
||||
expect(await fs.readFile(savedPath, 'utf8')).toBe('png bytes');
|
||||
expect(Log.log).toHaveBeenCalledWith(` Saved: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(` Output: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
expect(jest.mocked(Log.gray).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestAsync.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('reports completion for actions with empty output', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(jest.mocked(ora).mock.results[1].value.succeed).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[1\/1\] hardwareKeyboard\.set /)
|
||||
);
|
||||
});
|
||||
|
||||
it('formats structured results without opening a picker', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"text":"Imported text"}');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(' {\n "text": "Imported text"\n }');
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
@@ -186,6 +393,7 @@ describe(SimulatorControl, () => {
|
||||
it('runs an action from the catalog with its values and prints the output', async () => {
|
||||
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
|
||||
apiUrl: 'https://sim.example.test',
|
||||
@@ -196,8 +404,8 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
|
||||
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
|
||||
it('lists the actions of the session', async () => {
|
||||
await createCommand(['--list', '--id', 'session-123']).runAsync();
|
||||
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
|
||||
@@ -205,6 +413,7 @@ describe(SimulatorControl, () => {
|
||||
Object.keys(catalog).sort()
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
|
||||
@@ -278,12 +487,45 @@ describe(SimulatorControl, () => {
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: expect.objectContaining({ udid: 'simulator-udid' }),
|
||||
}),
|
||||
action: 'permissions.list',
|
||||
output: { voiceover: 'off' },
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints plain text as a JSON string and lists a catalog as JSON without opening the menu', async () => {
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--json',
|
||||
]).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
project: '@testuser/testapp',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
action: 'hardwareKeyboard.set',
|
||||
output: 'off',
|
||||
file: null,
|
||||
});
|
||||
mockPrintJsonOnlyOutput.mockClear();
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockClear();
|
||||
await createCommand(['--list', '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'hardwareKeyboard.set': catalog['hardwareKeyboard.set'] })
|
||||
);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -522,12 +764,54 @@ describe(SimulatorControl, () => {
|
||||
|
||||
it('refuses an action that the session does not list', async () => {
|
||||
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control --list` to see the actions.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the interactive menu with one connection and catalog', async () => {
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(runControlMenuAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
catalog,
|
||||
runActionAsync: expect.any(Function),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(requestAsync).toHaveBeenCalledTimes(1);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['--json', '--non-interactive'])('requires an action in scripted mode %s', async flag => {
|
||||
await expect(createCommand(['--id', 'session-123', flag]).runAsync()).rejects.toThrow(
|
||||
'No action provided.'
|
||||
);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires an action without a TTY before it loads the session', async () => {
|
||||
process.stdin.isTTY = false;
|
||||
try {
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'No action provided.'
|
||||
);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.stdin.isTTY = true;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects --output without an action before connecting', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', '--output', './out.png']).runAsync()
|
||||
).rejects.toThrow('--output requires an action');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an established connection when catalog setup fails', async () => {
|
||||
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
|
||||
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
@@ -536,8 +820,159 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
it('runs an interactive selection through the shared execution path', async () => {
|
||||
jest.mocked(runControlMenuAsync).mockImplementationOnce(async ({ runActionAsync }) => {
|
||||
await runActionAsync({ action: 'hardwareKeyboard.set', params: { value: 'off' } });
|
||||
});
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(Log.log).toHaveBeenCalledWith('off');
|
||||
});
|
||||
|
||||
it('cleans up before returning Ctrl-C status', async () => {
|
||||
const previousExitCode = process.exitCode;
|
||||
jest.mocked(runControlMenuAsync).mockRejectedValueOnce(new ControlPromptCancelledError());
|
||||
try {
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(process.exitCode).toBe(130);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('detaches on SIGINT while the menu is idle and removes its handler', async () => {
|
||||
const previousExitCode = process.exitCode;
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
jest.mocked(runControlMenuAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
const interrupted = new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
process.emit('SIGINT');
|
||||
await interrupted;
|
||||
});
|
||||
try {
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(process.exitCode).toBe(130);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports connection loss while idle and restores its listeners', async () => {
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
const reason = new Error('Connection lost; the action may still have run.');
|
||||
jest.mocked(runControlMenuAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
const lost = new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
connectionController.abort(reason);
|
||||
await lost;
|
||||
});
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toBe(reason);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not run an action when the socket closes immediately after catalog setup', async () => {
|
||||
const reason = new Error('Connection closed after catalog');
|
||||
requestAsync.mockImplementationOnce(async () => {
|
||||
queueMicrotask(() => {
|
||||
connectionController.abort(reason);
|
||||
});
|
||||
return ok(JSON.stringify(served));
|
||||
});
|
||||
jest.mocked(runControlMenuAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
signal.throwIfAborted();
|
||||
});
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toBe(reason);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ends the idle menu on confirmed session completion without stopping the remote session', async () => {
|
||||
const watcher = new AbortController();
|
||||
const dispose = jest.fn();
|
||||
const watch = jest
|
||||
.spyOn(controlSession, 'watchControlSession')
|
||||
.mockReturnValueOnce({ signal: watcher.signal, dispose });
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
jest.mocked(runControlMenuAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
const ended = new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
watcher.abort(new controlSession.ControlSessionEndedError('session-123'));
|
||||
await ended;
|
||||
});
|
||||
try {
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith('Simulator session session-123 has ended.');
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
watch.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('propagates an errored session instead of reporting a successful end', async () => {
|
||||
jest
|
||||
.mocked(runControlMenuAsync)
|
||||
.mockRejectedValueOnce(
|
||||
new controlSession.ControlSessionEndedError('session-123', /* failed */ true)
|
||||
);
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'session-123 errored'
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).not.toHaveBeenCalledWith('Simulator session session-123 has ended.');
|
||||
});
|
||||
|
||||
it('shows session and device details without terminal escape sequences', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({ name: '\x1b[31mCheckout QA\x1b[0m\nSpoofed\u202E' })
|
||||
);
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
const text = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([value]) => String(value))
|
||||
.join('\n');
|
||||
expect(text).toContain('Checkout QA Spoofed');
|
||||
expect(text).toContain('@testuser/testapp');
|
||||
expect(text).toContain('iPhone 17 Pro · iOS 26.4');
|
||||
expect(text).toContain('session-123');
|
||||
expect(text).toContain('simulator-udid');
|
||||
expect(text).not.toContain('\x1b[31m');
|
||||
expect(text).not.toContain('\u202E');
|
||||
expect(text).not.toContain('preview-token');
|
||||
});
|
||||
|
||||
it('includes upload path help in the JSON action list', async () => {
|
||||
await createCommand(['--list', '--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -550,6 +985,105 @@ describe(SimulatorControl, () => {
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows fallback names when optional details are unavailable or blank', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession({ name: ' \n ' }));
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockResolvedValueOnce({ udid: 'simulator-udid' });
|
||||
await createCommand(['--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unnamed session'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ importing: true, jsonFlag: false },
|
||||
{ importing: true, jsonFlag: true },
|
||||
{ importing: false, jsonFlag: true },
|
||||
])(
|
||||
'runs actions without display metadata (importing: $importing, json: $jsonFlag)',
|
||||
async ({ importing, jsonFlag }) => {
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/hostActions').fetchHostActionDeviceAsync
|
||||
);
|
||||
const fetch = jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockRejectedValueOnce(new Error('Metadata request failed'));
|
||||
try {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand([
|
||||
...(importing ? ['--import', filePath] : ['hardwareKeyboard.set', 'off']),
|
||||
...(jsonFlag ? ['--json'] : []),
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
if (jsonFlag) {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ device: { udid: 'simulator-udid' } }),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
}
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
it('closes the connection if the interactive controller fails unexpectedly', async () => {
|
||||
const reason = new Error('Terminal output failed');
|
||||
jest.mocked(runControlMenuAsync).mockRejectedValueOnce(reason);
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toBe(reason);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requires a session ID before opening a connection', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
try {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(
|
||||
'No simulator session ID provided.'
|
||||
);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the session ID loaded into the simulator environment', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'dotenv-session';
|
||||
try {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-session');
|
||||
expect(runControlMenuAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses Android before an interactive prompt or connection', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'not supported yet'
|
||||
);
|
||||
expect(runControlMenuAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
|
||||
@@ -33,11 +33,14 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import { applyStartConfigAsync } from '../../../simulator/startSetup';
|
||||
import { ControlConfigExecutionError } from '../../../simulator/controlConfig';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../simulator/startSetup');
|
||||
jest.mock('../../../analytics/AnalyticsManager', () => ({
|
||||
...jest.requireActual('../../../analytics/AnalyticsManager'),
|
||||
isAnalyticsOptedOutAsync: jest.fn(),
|
||||
@@ -195,6 +198,7 @@ describe(Simulator, () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(applyStartConfigAsync).mockReset().mockResolvedValue([]);
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
@@ -203,7 +207,7 @@ describe(Simulator, () => {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockByIdAsync.mockReset().mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
@@ -238,6 +242,804 @@ describe(Simulator, () => {
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
describe('imported session configuration', () => {
|
||||
const configPath = '/test/simulator.json';
|
||||
const localEgressSession = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
},
|
||||
});
|
||||
function config(start: object = { platform: 'ios' }, actions?: object[]): void {
|
||||
jest
|
||||
.mocked(fs.readJson)
|
||||
.mockResolvedValue({ version: 1, start, ...(actions ? { actions } : {}) } as never);
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('enables JSON output before import preflight can fail', async () => {
|
||||
config({ unknown: true });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath, '--json']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(fs.readJson).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates from saved flags with explicit CLI overrides and unchanged output without actions', async () => {
|
||||
config({
|
||||
platform: 'ios',
|
||||
name: 'Saved name',
|
||||
type: 'web-preview-only',
|
||||
device: 'Saved phone',
|
||||
tag: ['saved'],
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--name', 'CLI name', '--json']);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
name: 'CLI name',
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
ios: { deviceIdentifier: 'Saved phone' },
|
||||
tags: ['saved'],
|
||||
})
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(printJsonOnlyOutput)).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ setup: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it.each(
|
||||
(['ios', 'android'] as const).flatMap(platform =>
|
||||
(['attached', 'detached', 'json'] as const).flatMap(mode =>
|
||||
(['env', 'dotenv'] as const).flatMap(output =>
|
||||
[false, true].map(egress => ({ platform, mode, output, egress }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)(
|
||||
'preserves ordinary startup with saved settings: $platform/$mode/$output, egress=$egress',
|
||||
async ({ platform, mode, output, egress }) => {
|
||||
const listeners = process.listeners('SIGINT');
|
||||
const start = {
|
||||
platform,
|
||||
type: 'web-preview-only',
|
||||
name: 'Startup regression',
|
||||
device: platform === 'ios' ? 'iPhone 17 Pro' : 'pixel_7',
|
||||
'out-config-type': output,
|
||||
...(egress ? { egress: 'local' } : {}),
|
||||
};
|
||||
const ready = makeDeviceRunSession({
|
||||
platform: platform === 'ios' ? AppPlatform.Ios : AppPlatform.Android,
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
...(egress
|
||||
? {
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const runAsync = async (saved: boolean): Promise<object> => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({ version: 1, start } as never);
|
||||
mockByIdAsync
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValue({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest.mocked(runLocalEgressAsync).mockImplementation(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const argv = saved
|
||||
? ['--import', configPath]
|
||||
: Object.entries(start).flatMap(([name, value]) => [`--${name}`, value]);
|
||||
if (mode !== 'attached') {
|
||||
argv.push(mode === 'json' ? '--json' : '--non-interactive');
|
||||
}
|
||||
await createCommand(argv).command.runAsync();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(fs.readJson).toHaveBeenCalledTimes(saved ? 1 : 0);
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledTimes(egress && mode === 'attached' ? 1 : 0);
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(
|
||||
output === 'dotenv' ? (mode === 'json' ? 1 : 2) : 0
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
return {
|
||||
create: mockCreateDeviceRunSessionAsync.mock.calls,
|
||||
output: jest.mocked(Log.log).mock.calls,
|
||||
warnings: jest.mocked(Log.warn).mock.calls,
|
||||
newLines: jest.mocked(Log.newLine).mock.calls,
|
||||
json: mockPrintJsonOnlyOutput.mock.calls,
|
||||
writes: jest.mocked(fs.writeFile).mock.calls,
|
||||
cleanup: mockResetSimulatorEnvAsync.mock.calls,
|
||||
stop: mockEnsureDeviceRunSessionStoppedAsync.mock.calls,
|
||||
egress: jest.mocked(runLocalEgressAsync).mock.calls.map(([options]) => ({
|
||||
url: options.url,
|
||||
port: options.port,
|
||||
platform: options.platform,
|
||||
aborted: options.signal.aborted,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const ordinary = await runAsync(false);
|
||||
expect(await runAsync(true)).toEqual(ordinary);
|
||||
}
|
||||
);
|
||||
|
||||
it('applies actions after readiness and includes parsed setup results in one JSON object', async () => {
|
||||
config(undefined, [{ action: 'appearance.set', params: { value: 'dark' } }]);
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
return [{ action: 'appearance.set', output: '{"value":"dark"}', file: null }];
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--json']);
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(applyStartConfigAsync).mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://sim.example.test', token: 'private-token' },
|
||||
expect.objectContaining({ path: configPath }),
|
||||
{ signal: expect.any(AbortSignal), onProgress: undefined }
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
setup: {
|
||||
config: configPath,
|
||||
results: [{ action: 'appearance.set', output: { value: 'dark' }, file: null }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'prints normal ready output before importing, detached=%s',
|
||||
async detached => {
|
||||
config({ platform: 'ios' }, [{ action: 'tap', params: { x: 0.5, y: 0.5 } }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
const importing = output.findIndex(line => line.includes('Importing 1 action from'));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(importing).toBeGreaterThan(preview);
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'running' });
|
||||
const result = { action: 'tap', output: '', file: null };
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'completed', result });
|
||||
return [result];
|
||||
});
|
||||
await createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(detached ? ['--non-interactive'] : []),
|
||||
]).command.runAsync();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining(`${EAS_SIMULATOR_SESSION_ID}='session-123'`)
|
||||
);
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.succeed.mock.calls.some(([text]: [string]) => text === '[1/1] tap completed')
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('identifies failed and skipped imported steps after printing the preview', async () => {
|
||||
config({ platform: 'ios' }, [
|
||||
{ action: 'appearance.get' },
|
||||
{ action: 'tap' },
|
||||
{ action: 'clipboard.read' },
|
||||
]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValueOnce(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'tap',
|
||||
[{ action: 'appearance.get', output: 'dark', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('refused');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(' [3/3] clipboard.read skipped');
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.fail.mock.calls.some(([text]: [string]) => text === '[2/3] tap failed')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('prints imported text and saved files after the ready preview URL', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }, { action: 'screenshot.capture' }]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
const results = [
|
||||
{ action: 'appearance.get', output: 'dark', file: null },
|
||||
{ action: 'screenshot.capture', output: '', file: '/test/screenshot.png' },
|
||||
];
|
||||
for (const [index, result] of results.entries()) {
|
||||
options.onProgress?.({
|
||||
step: index + 1,
|
||||
total: results.length,
|
||||
action: result.action,
|
||||
status: 'completed',
|
||||
result,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
});
|
||||
await createCommand(['--import', configPath, '--non-interactive']).command.runAsync();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(output.indexOf('dark')).toBeGreaterThan(preview);
|
||||
expect(output.indexOf('Saved /test/screenshot.png')).toBeGreaterThan(preview);
|
||||
});
|
||||
|
||||
it('preserves the setup error when the stopped session dotenv cannot be cleaned up', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValueOnce(new Error('native action refused'));
|
||||
mockResetSimulatorEnvAsync.mockRejectedValueOnce(new Error('read-only file'));
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('native action refused');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not be cleaned up: read-only file')
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops only the newly created session on setup failure and retains completed JSON results', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }, { action: 'appearance.get' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValue(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'appearance.get',
|
||||
[{ action: 'appearance.set', output: '"dark"', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
const prior = process.exitCode;
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
stopped: true,
|
||||
setup: expect.objectContaining({
|
||||
results: [{ action: 'appearance.set', output: 'dark', file: null }],
|
||||
error: expect.objectContaining({ step: 2, action: 'appearance.get' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ imported: false, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: false },
|
||||
])(
|
||||
'stops startup interrupted during its final dotenv write, imported=$imported, json=$jsonFlag',
|
||||
async ({ imported, jsonFlag }) => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
config(undefined, imported ? [{ action: 'appearance.get' }] : undefined);
|
||||
jest.mocked(fs.writeFile).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(jsonFlag ? ['--json'] : ['--non-interactive']),
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['interactive', 'detached', 'json'].flatMap(mode =>
|
||||
['failure', 'interruption'].map(outcome => ({ mode, outcome }))
|
||||
)
|
||||
)(
|
||||
'keeps previous session credentials after imported $outcome in $mode mode',
|
||||
async ({ mode, outcome }) => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
const previous =
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
"EAS_SIMULATOR_SESSION_ID='previous-session'\nEAS_SIMULATOR_EGRESS_TOKEN='previous-token'\n";
|
||||
let saved = previous;
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => saved as never);
|
||||
jest.mocked(fs.writeFile).mockImplementation(async (_path, content) => {
|
||||
saved = String(content);
|
||||
});
|
||||
mockResetSimulatorEnvAsync.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/env').resetSimulatorEnvAsync
|
||||
);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (outcome === 'interruption') {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw new Error('setup failed');
|
||||
});
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('interrupted');
|
||||
});
|
||||
const priorExitCode = process.exitCode;
|
||||
const listeners = process.listeners('SIGINT');
|
||||
try {
|
||||
const command = createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(mode === 'json' ? ['--json'] : mode === 'detached' ? ['--non-interactive'] : []),
|
||||
]).command;
|
||||
if (mode === 'json' && outcome === 'failure') {
|
||||
await command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} else {
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
outcome === 'interruption' ? 'interrupted' : 'setup failed'
|
||||
);
|
||||
}
|
||||
expect(saved).toBe(previous);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
jest.mocked(fs.readFile).mockReset();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('reports unconfirmed cleanup rather than implying the session stopped', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValue(new Error('setup failed'));
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('network failed'));
|
||||
const prior = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopped: false })
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('simulator:stop --id session-123')
|
||||
);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects Android actions and invalid files before creating a session', async () => {
|
||||
config({ platform: 'android' }, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('not supported on Android');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
config({ typo: 'value' });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath]);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects explicit capture fields when the CLI disables saved capture', async () => {
|
||||
config({ platform: 'ios', 'network-capture': true });
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
'--no-network-capture',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('--network-capture-field requires --network-capture');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Android start settings without actions', async () => {
|
||||
config({ platform: 'android' });
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ platform: AppPlatform.Android })
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the existing platform prompt and attached-session lifecycle in interactive mode', async () => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Ios });
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(mockPromptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Select platform' })
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'refuses an incomplete platform without prompting: %j',
|
||||
async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('platform flag must be set');
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['CI', 'pipe'])('inherits non-interactive defaults for %s', async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const priorCi = process.env.CI;
|
||||
const priorTty = process.stdin.isTTY;
|
||||
try {
|
||||
if (mode === 'CI') {
|
||||
process.env.CI = 'true';
|
||||
} else {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true });
|
||||
}
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'platform flag must be set'
|
||||
);
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (priorCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = priorCi;
|
||||
}
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: priorTty, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('connects interactive local egress before applying imported actions and closes it on session end', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.set' }]);
|
||||
const ready = localEgressSession;
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
let connected = false;
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
connected = true;
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(connected).toBe(true);
|
||||
return [];
|
||||
});
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'rejects actions with detached local egress before creating a session: %j',
|
||||
async mode => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('Imported actions with local egress require interactive mode');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('cleans up and preserves an egress connection failure before setup', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error('egress could not bind');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops setup instead of running without requested local egress settings', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'did not provide local egress connection settings'
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['action', 'egress'])(
|
||||
'preserves a %s failure during connected setup and closes the tunnel',
|
||||
async source => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error(`${source} failed during setup`);
|
||||
let rejectTunnel: (error: Error) => void = () => {};
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
tunnelClosed = true;
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (source === 'action') {
|
||||
throw failure;
|
||||
}
|
||||
rejectTunnel(failure);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new Error('action aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the wait for local egress and closes the tunnel on timeout', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const result = expect(
|
||||
createCommand(['--import', configPath]).command.runAsync()
|
||||
).rejects.toThrow('did not connect within 120 seconds');
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
await result;
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes local egress and stops the session on Ctrl-C before the tunnel connects', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const interrupted = new Error('exit 130');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
setImmediate(() => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
});
|
||||
tunnelClosed = true;
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
interrupted
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops on Ctrl-C during setup and removes the session handler', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
const interrupted = new Error('exit 130');
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementation(async (_access, _config, { signal }) => {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
return [];
|
||||
});
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toBe(interrupted);
|
||||
expect(exit).toHaveBeenCalledWith(130);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with the waitlist link without creating a session when the account is gated', async () => {
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
@@ -1831,7 +2633,14 @@ describe(Simulator, () => {
|
||||
'stops %s after an interactive egress failure and removes its interrupt handler',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
const failure = new Error('listen EADDRINUSE');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining(deviceRunSessionUrl));
|
||||
throw failure;
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
@@ -12,6 +12,23 @@ import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
ControlAction,
|
||||
executeControlActionAsync,
|
||||
parseControlActionOutput,
|
||||
prepareControlActionAsync,
|
||||
} from '../../simulator/controlActions';
|
||||
import {
|
||||
ControlConfigExecutionError,
|
||||
ControlConfigResult,
|
||||
PreparedControlConfig,
|
||||
executeControlConfigAsync,
|
||||
prepareControlConfigAsync,
|
||||
serializeControlConfigResults,
|
||||
} from '../../simulator/controlConfig';
|
||||
import { runControlMenuAsync } from '../../simulator/controlInteractive';
|
||||
import { ControlPromptCancelledError } from '../../simulator/controlPrompts';
|
||||
import { ControlSessionEndedError, watchControlSession } from '../../simulator/controlSession';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -21,28 +38,53 @@ import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
HostActionDevice,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../../simulator/hostActions';
|
||||
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sanitizeTerminalText, truncate } from '../../utils/terminalText';
|
||||
import { formatMilliseconds } from '../../utils/timer';
|
||||
|
||||
type ControlSession = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
project: string;
|
||||
status: DeviceRunSessionStatus;
|
||||
device: HostActionDevice;
|
||||
};
|
||||
|
||||
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 description = `[EXPERIMENTAL] control a remote iOS simulator session on EAS
|
||||
|
||||
Run without an action to open the menu, or use --list to see available actions. Use <action> --help for usage.
|
||||
|
||||
To save a screenshot locally, run \`eas simulator:control screenshot.capture --output ./screenshot.png\`.
|
||||
|
||||
Use --import to run saved actions from a JSON file:
|
||||
{
|
||||
"version": 1,
|
||||
"actions": [{"action": "appearance.set", "params": {"value": "dark"}}]
|
||||
}
|
||||
|
||||
The file is checked before any actions run. Actions run in order and stop on the first failure. Local paths are relative to the JSON file; paths in params refer to the simulator host.
|
||||
|
||||
Existing output files are overwritten. When saving to a directory, an existing file with the generated name causes the action to fail.`;
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
'$ eas simulator:control',
|
||||
'$ eas simulator:control --list',
|
||||
'$ eas simulator:control --id <session-id>',
|
||||
'$ eas simulator:control screenshot.capture --help',
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control --import ./simulator-control.json',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.ipa',
|
||||
@@ -53,24 +95,26 @@ export default class SimulatorControl extends EasCommand {
|
||||
static override args = {
|
||||
action: Args.string({
|
||||
description:
|
||||
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.',
|
||||
'Action name, followed by values or name=value pairs. Omit to open the menu. Use -- before values starting with --.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
help: Flags.boolean({
|
||||
char: 'h',
|
||||
description: 'Show command help, or usage for the selected action from the session.',
|
||||
description: 'Show help for this command or the selected action',
|
||||
}),
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
list: Flags.boolean({
|
||||
description: 'List the actions the session supports.',
|
||||
description: 'List available actions',
|
||||
}),
|
||||
output: Flags.string({
|
||||
description:
|
||||
'Local 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.',
|
||||
description: 'Local file or existing directory for saved output',
|
||||
}),
|
||||
import: Flags.string({
|
||||
description: 'Run saved actions from a JSON file',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -91,6 +135,21 @@ export default class SimulatorControl extends EasCommand {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
const action = flags.list && !flags.help ? undefined : args.action;
|
||||
const importing = flags.import !== undefined;
|
||||
if (importing && (args.action || flags.list || flags.output !== undefined)) {
|
||||
throw new Error('--import cannot be combined with an action, --list, or --output.');
|
||||
}
|
||||
const interactive = !flags.list && !action && !importing;
|
||||
if (!action && flags.output) {
|
||||
throw new Error(
|
||||
'--output requires an action. Choose an output path when selecting a file action in the interactive menu.'
|
||||
);
|
||||
}
|
||||
if (interactive && (nonInteractive || !process.stdin.isTTY)) {
|
||||
throw new Error(
|
||||
'No action provided. Run `eas simulator:control --list` to see the actions, then `eas simulator:control <action> [values...]`.'
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
@@ -111,41 +170,219 @@ export default class SimulatorControl extends EasCommand {
|
||||
let connection: HostActionConnection | undefined;
|
||||
try {
|
||||
let catalog: HostActionCatalog;
|
||||
let controlSession: ControlSession;
|
||||
try {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(assertControllableSession(session));
|
||||
const access = assertControllableSession(session);
|
||||
connection = await connectHostActionsAsync(access);
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
controlSession = {
|
||||
id: session.id,
|
||||
name: session.name ?? null,
|
||||
project: `@${session.app.ownerAccount.name}/${session.app.slug}`,
|
||||
status: session.status,
|
||||
device:
|
||||
interactive || importing || (jsonFlag && !flags.list && !flags.help)
|
||||
? await fetchHostActionDeviceAsync(access, connection.udid)
|
||||
: { udid: connection.udid },
|
||||
};
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
|
||||
throw err;
|
||||
}
|
||||
if (!action) {
|
||||
if (flags.list && !flags.help) {
|
||||
this.printCatalog(catalog, jsonFlag);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
|
||||
);
|
||||
}
|
||||
if (flags.help) {
|
||||
if (flags.help && action) {
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control --list\` to see the actions.`
|
||||
);
|
||||
}
|
||||
this.printActionHelp(action, entry, jsonFlag);
|
||||
return;
|
||||
}
|
||||
await this.runActionAsync(connection, entry, {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
const activeConnection = connection;
|
||||
const runActionAsync = async (selection: ControlAction): Promise<void> => {
|
||||
await this.runActionAsync(activeConnection, catalog, selection, {
|
||||
session: controlSession,
|
||||
jsonFlag,
|
||||
interactive,
|
||||
});
|
||||
};
|
||||
if (interactive) {
|
||||
this.printSessionHeader(controlSession);
|
||||
const controller = new AbortController();
|
||||
const onConnectionLost = (): void => {
|
||||
controller.abort(activeConnection.signal.reason);
|
||||
};
|
||||
const onInterrupt = (): void => {
|
||||
controller.abort(new ControlPromptCancelledError());
|
||||
};
|
||||
activeConnection.signal.addEventListener('abort', onConnectionLost, { once: true });
|
||||
if (activeConnection.signal.aborted) {
|
||||
onConnectionLost();
|
||||
}
|
||||
process.on('SIGINT', onInterrupt);
|
||||
const session = watchControlSession(graphqlClient, sessionId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const detach = (): void => {
|
||||
activeConnection.close();
|
||||
};
|
||||
session.signal.addEventListener('abort', detach, { once: true });
|
||||
try {
|
||||
await runControlMenuAsync({
|
||||
catalog,
|
||||
signal: session.signal,
|
||||
runActionAsync,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason: unknown = session.signal.aborted ? session.signal.reason : error;
|
||||
if (reason instanceof ControlPromptCancelledError) {
|
||||
process.exitCode = 130;
|
||||
} else if (reason instanceof ControlSessionEndedError) {
|
||||
if (reason.failed) {
|
||||
throw reason;
|
||||
}
|
||||
Log.log(reason.message);
|
||||
} else {
|
||||
throw reason;
|
||||
}
|
||||
} finally {
|
||||
session.signal.removeEventListener('abort', detach);
|
||||
session.dispose();
|
||||
activeConnection.signal.removeEventListener('abort', onConnectionLost);
|
||||
process.removeListener('SIGINT', onInterrupt);
|
||||
}
|
||||
} else if (importing) {
|
||||
await this.importConfigAsync(activeConnection, catalog, flags.import!, {
|
||||
session: controlSession,
|
||||
jsonFlag,
|
||||
});
|
||||
} else {
|
||||
const entry = Object.hasOwn(catalog, action!) ? catalog[action!] : undefined;
|
||||
await runActionAsync({
|
||||
action: action!,
|
||||
params: entry ? buildHostActionParams(action!, entry, argv.slice(1).map(String)) : {},
|
||||
outputPath: flags.output,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async importConfigAsync(
|
||||
connection: HostActionConnection,
|
||||
catalog: HostActionCatalog,
|
||||
filePath: string,
|
||||
{ session, jsonFlag }: { session: ControlSession; jsonFlag: boolean }
|
||||
): Promise<void> {
|
||||
const spinner = jsonFlag ? undefined : ora(`Validating ${path.basename(filePath)}`).start();
|
||||
const startedAt = Date.now();
|
||||
let stepStartedAt = startedAt;
|
||||
let config: PreparedControlConfig | undefined;
|
||||
let results: ControlConfigResult[];
|
||||
try {
|
||||
config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
if (spinner) {
|
||||
spinner.succeed(`Validated ${formatActionCount(config.actions.length)}`);
|
||||
this.printSessionHeader(session);
|
||||
Log.log(chalk.bold(path.basename(config.path)));
|
||||
Log.gray(config.path);
|
||||
for (const { plan } of config.actions) {
|
||||
if (plan.localFile) {
|
||||
Log.gray(` Read: ${plan.localFile.path}`);
|
||||
}
|
||||
if (plan.outputPath) {
|
||||
Log.gray(` Output: ${plan.outputPath}`);
|
||||
}
|
||||
}
|
||||
if (config.actions.some(({ plan }) => plan.outputPath)) {
|
||||
Log.gray(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
const prepared = config;
|
||||
results = await executeControlConfigAsync(connection, prepared, {
|
||||
onProgress: spinner
|
||||
? progress => {
|
||||
const label = `[${progress.step}/${progress.total}] ${progress.action}`;
|
||||
if (progress.status === 'running') {
|
||||
stepStartedAt = Date.now();
|
||||
spinner.start(label);
|
||||
} else {
|
||||
spinner.succeed(`${label} ${chalk.dim(formatElapsed(stepStartedAt))}`);
|
||||
this.printConfigResult(progress.result);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ControlConfigExecutionError && config) {
|
||||
spinner?.fail(
|
||||
`[${error.step}/${config.actions.length}] ${error.action} ${chalk.dim(formatElapsed(stepStartedAt))}`
|
||||
);
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(error.completed),
|
||||
error: { step: error.step, action: error.action, message: error.message },
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const skipped = config.actions.length - error.step;
|
||||
Log.newLine();
|
||||
Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`);
|
||||
for (const [index, { plan }] of config.actions.entries()) {
|
||||
if (index >= error.step) {
|
||||
Log.gray(` [${index + 1}/${config.actions.length}] ${plan.action} skipped`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spinner?.fail(config ? 'Import failed' : 'Validation failed. No actions were run.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(results),
|
||||
});
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.succeed(
|
||||
`Import complete · ${formatActionCount(results.length)} · ${formatElapsed(startedAt)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printConfigResult(result: ControlConfigResult): void {
|
||||
if (result.file) {
|
||||
Log.log(` Saved: ${result.file}`);
|
||||
} else if (result.output) {
|
||||
const output = parseControlActionOutput(result.output);
|
||||
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
||||
Log.log(
|
||||
text
|
||||
.split('\n')
|
||||
.map(line => ` ${line}`)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(
|
||||
@@ -210,71 +447,86 @@ export default class SimulatorControl extends EasCommand {
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private printSessionHeader(session: ControlSession): void {
|
||||
const clean = (value: string): string =>
|
||||
sanitizeTerminalText(
|
||||
stripVTControlCharacters(value).replace(/[\u202A-\u202E\u2066-\u2069]/g, '')
|
||||
);
|
||||
const runtime = clean(session.device.runtime ?? '')
|
||||
.replace(/^([A-Za-z]+)-/, '$1 ')
|
||||
.replaceAll('-', '.');
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('Simulator controls'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Session', value: truncate(clean(session.name ?? '') || 'Unnamed session', 80) },
|
||||
{ label: 'Project', value: clean(session.project) },
|
||||
{
|
||||
label: 'Device',
|
||||
value: [truncate(clean(session.device.name ?? '') || 'Unavailable', 80), runtime]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
},
|
||||
{ label: 'State', value: session.status.toLowerCase().replaceAll('_', ' ') },
|
||||
{ label: 'Session ID', value: clean(session.id) },
|
||||
{ label: 'Device ID', value: clean(session.device.udid) },
|
||||
])
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private async runActionAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
catalog: HostActionCatalog,
|
||||
selection: ControlAction,
|
||||
{
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
outputPath,
|
||||
session,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
interactive,
|
||||
}: { session: ControlSession; jsonFlag: boolean; interactive: boolean }
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
const { action } = selection;
|
||||
const plan = await prepareControlActionAsync(catalog, selection);
|
||||
const startedAt = Date.now();
|
||||
const spinner = ora(
|
||||
interactive ? `Running ${action}` : `Running ${action} on simulator session ${session.id}`
|
||||
).start();
|
||||
let output: string;
|
||||
let savedFile: string | null;
|
||||
try {
|
||||
if (localFile) {
|
||||
spinner.text = `Uploading ${localFile.path}`;
|
||||
const upload = await uploadFileAsync(connection, localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
spinner.text = `Running ${action} on simulator session ${sessionId}`;
|
||||
}
|
||||
const result = await connection.requestAsync(action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
|
||||
output = savedFile ? '' : result.stdout.trim();
|
||||
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
|
||||
({ output, file: savedFile } = await executeControlActionAsync(connection, plan));
|
||||
spinner.succeed(
|
||||
interactive
|
||||
? `${action} complete · ${formatElapsed(startedAt)}`
|
||||
: `Ran ${action} on simulator session ${session.id}`
|
||||
);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
|
||||
spinner.fail(
|
||||
interactive
|
||||
? `${action} failed`
|
||||
: `Failed to run ${action} on simulator session ${session.id}`
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: sessionId,
|
||||
id: session.id,
|
||||
session,
|
||||
action,
|
||||
output: parseJsonOutput(output),
|
||||
output: parseControlActionOutput(output),
|
||||
file: savedFile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interactive && plan.localFile?.path) {
|
||||
Log.gray(`Source: ${plan.localFile.path}`);
|
||||
}
|
||||
if (savedFile) {
|
||||
Log.log(`Saved ${savedFile}`);
|
||||
} else if (output) {
|
||||
Log.log(output);
|
||||
const parsed = parseControlActionOutput(output);
|
||||
Log.log(interactive && typeof parsed !== 'string' ? JSON.stringify(parsed, null, 2) : output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,45 +547,12 @@ function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEnt
|
||||
: entry;
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('host:')) {
|
||||
if (uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
function formatElapsed(startedAt: number): string {
|
||||
return formatMilliseconds(Date.now() - startedAt) || '<1s';
|
||||
}
|
||||
|
||||
function formatActionCount(count: number): string {
|
||||
return `${count} action${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function assertControllableSession(
|
||||
@@ -346,7 +565,7 @@ function assertControllableSession(
|
||||
}
|
||||
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.`
|
||||
`Simulator control on ${session.platform.toLowerCase()} is not supported yet. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
@@ -357,11 +576,3 @@ function assertControllableSession(
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { Ora, ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import {
|
||||
ControlConfigExecutionError,
|
||||
serializeControlConfigResults,
|
||||
} from '../../simulator/controlConfig';
|
||||
import { resolveStartConfigAsync } from '../../simulator/startConfig';
|
||||
import { applyStartConfigAsync } from '../../simulator/startSetup';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import { parseCorsOriginList, parseFrameAncestorList } from '../../simulator/frameAncestors';
|
||||
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
|
||||
@@ -59,12 +65,15 @@ import {
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getPreviewApiAccess,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
// Allow the cold-cache client download (up to 60s) and a further minute to connect.
|
||||
const EGRESS_SETUP_TIMEOUT_MS = 120_000;
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue
|
||||
const OUT_CONFIG_TYPE_VALUES = {
|
||||
@@ -82,10 +91,30 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
export default class Simulator extends EasCommand {
|
||||
static override aliases = ['simulator:start', 'sim:start'];
|
||||
static override hiddenAliases = ['sim'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in';
|
||||
static override description = `[EXPERIMENTAL] start a remote simulator session on EAS
|
||||
|
||||
Preview links require sign-in by default.
|
||||
|
||||
Use --import to load saved start options and actions. CLI flags override saved options, including lists:
|
||||
{
|
||||
"version": 1,
|
||||
"start": {"platform": "ios"},
|
||||
"actions": [{"action": "appearance.set", "params": {"value": "dark"}}]
|
||||
}
|
||||
|
||||
The start object uses CLI flag names. Saved actions run after the session is ready. Local action paths are relative to the JSON file. If an action fails, the CLI tries to stop the new session. Android supports saved start options, but not actions yet.
|
||||
|
||||
With local egress, actions wait for the connection and require interactive mode. In scripts, connect with simulator:egress, then run simulator:control --import separately.`;
|
||||
|
||||
static override examples = [
|
||||
'$ eas simulator:start --import ./simulator.json',
|
||||
'$ eas simulator:start --import ./simulator.json --device "iPhone 17 Pro" --json',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
import: Flags.string({
|
||||
description: 'Load start options and actions from a JSON file',
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
@@ -151,6 +180,7 @@ export default class Simulator extends EasCommand {
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'network-capture': Flags.boolean({
|
||||
allowNo: true,
|
||||
description:
|
||||
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
|
||||
}),
|
||||
@@ -208,6 +238,7 @@ export default class Simulator extends EasCommand {
|
||||
default: OUT_CONFIG_TYPE_VALUES.Dotenv,
|
||||
})(),
|
||||
'public-link': Flags.boolean({
|
||||
allowNo: true,
|
||||
description:
|
||||
'Enable the simulator preview Share button and print a shareable link containing an access token. Anyone with the link can control the simulator. With --json, include previewApiUrl and its token for API clients when available.',
|
||||
}),
|
||||
@@ -222,12 +253,13 @@ export default class Simulator extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(Simulator);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
// Import preflight can fail before parsing; keep diagnostics off JSON stdout.
|
||||
if (this.argv.includes('--json')) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const imported = await resolveStartConfigAsync(this.argv, Simulator.flags);
|
||||
const { flags } = await this.parse(Simulator, imported.argv);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -297,6 +329,9 @@ export default class Simulator extends EasCommand {
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (networkCaptureFields.length && !flags['network-capture']) {
|
||||
throw new EasCommandError('--network-capture-field requires --network-capture.');
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
@@ -327,6 +362,18 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android && imported.config?.actions?.length) {
|
||||
throw new EasCommandError(
|
||||
'Imported control actions are not supported on Android yet. Remove actions to import only start settings, or use --platform ios.'
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractive && egress && imported.config?.actions?.length) {
|
||||
throw new EasCommandError(
|
||||
'Imported actions with local egress require interactive mode. Start without actions, connect eas simulator:egress, then run eas simulator:control --import with the same file.'
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -424,7 +471,9 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
sessionInterrupt = registerSessionInterrupt(deviceRunSessionId);
|
||||
const simulatorEnvWritten =
|
||||
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
!jsonFlag &&
|
||||
!imported.config?.actions?.length &&
|
||||
flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
? await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
})
|
||||
@@ -526,101 +575,270 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (sessionInterrupt.signal.aborted) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
sessionInterrupt.dispose();
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig: sanitizeRemoteConfigForJson(
|
||||
remoteConfig,
|
||||
deviceRunSessionPreviewUrl,
|
||||
flags['public-link'],
|
||||
{ frameAncestors, corsOrigins }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
privatePreviewUrl: deviceRunSessionPreviewUrl,
|
||||
publicLink: flags['public-link'],
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow, platform);
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
// The instructions above already tell the reader to run eas simulator:egress.
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// In interactive mode the egress client runs right here, for as long as the
|
||||
// session does. A session with a disconnected tunnel still bills, so Ctrl+C stops both.
|
||||
let egressPromise: Promise<void> | undefined;
|
||||
let egressError: Error | undefined;
|
||||
const egressAbortController = new AbortController();
|
||||
if (localEgress) {
|
||||
sessionInterrupt.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
egressAbortController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
let markEgressReady: () => void;
|
||||
const egressReady = new Promise<void>(resolve => {
|
||||
markEgressReady = resolve;
|
||||
});
|
||||
const onSessionAbort = (): void => {
|
||||
egressAbortController.abort();
|
||||
};
|
||||
const startLocalEgress = (): void => {
|
||||
if (localEgress && !nonInteractive) {
|
||||
sessionInterrupt.signal.addEventListener('abort', onSessionAbort, { once: true });
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
markEgressReady();
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
const writeReadySimulatorEnvAsync = async (): Promise<void> => {
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!jsonFlag && !imported.config?.actions?.length) {
|
||||
await writeReadySimulatorEnvAsync();
|
||||
}
|
||||
|
||||
if (sessionInterrupt.signal.aborted && !egressError) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonFlag) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
privatePreviewUrl: deviceRunSessionPreviewUrl,
|
||||
publicLink: flags['public-link'],
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
if (nonInteractive) {
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let setup:
|
||||
| { config: string; results: ReturnType<typeof serializeControlConfigResults> }
|
||||
| undefined;
|
||||
if (imported.config?.actions?.length) {
|
||||
const total = imported.config.actions.length;
|
||||
if (!jsonFlag) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Importing ${total} action${total === 1 ? '' : 's'} from ${imported.config.path}`
|
||||
);
|
||||
}
|
||||
startLocalEgress();
|
||||
const setupSpinner = ora('Importing simulator configuration').start();
|
||||
try {
|
||||
if (egress && !localEgress) {
|
||||
throw new Error('The ready session did not provide local egress connection settings.');
|
||||
}
|
||||
if (egressPromise) {
|
||||
setupSpinner.text = 'Waiting for local egress before applying configuration';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
egressReady,
|
||||
egressPromise,
|
||||
sessionInterrupt.abortPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error('Local egress did not connect within 120 seconds.'));
|
||||
}, EGRESS_SETUP_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
sessionInterrupt.signal.throwIfAborted();
|
||||
}
|
||||
const access = getPreviewApiAccess(remoteConfig);
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
'This session does not expose an authenticated preview API for control actions.'
|
||||
);
|
||||
}
|
||||
const results = await applyStartConfigAsync(access, imported.config, {
|
||||
signal: sessionInterrupt.signal,
|
||||
onProgress: jsonFlag
|
||||
? undefined
|
||||
: progress => {
|
||||
const label = `[${progress.step}/${progress.total}] ${progress.action}`;
|
||||
if (progress.status === 'running') {
|
||||
setupSpinner.start(label);
|
||||
} else {
|
||||
setupSpinner.succeed(`${label} completed`);
|
||||
if (progress.result.file) {
|
||||
Log.log(`Saved ${progress.result.file}`);
|
||||
} else if (progress.result.output) {
|
||||
Log.log(progress.result.output);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
sessionInterrupt.signal.throwIfAborted();
|
||||
setup = { config: imported.config.path, results: serializeControlConfigResults(results) };
|
||||
setupSpinner.succeed(
|
||||
`Import complete: ${total} action${total === 1 ? '' : 's'} completed`
|
||||
);
|
||||
} catch (caught) {
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
const error = egressError ?? caught;
|
||||
if (sessionInterrupt.signal.aborted && !egressError) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: setupSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof ControlConfigExecutionError) {
|
||||
setupSpinner.fail(`[${error.step}/${total}] ${error.action} failed`);
|
||||
if (!jsonFlag) {
|
||||
const skipped = total - error.step;
|
||||
Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`);
|
||||
for (const [index, action] of imported.config.actions.entries()) {
|
||||
if (index >= error.step) {
|
||||
Log.log(` [${index + 1}/${total}] ${action.action} skipped`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setupSpinner.fail('Simulator configuration import failed');
|
||||
}
|
||||
const stopped = await ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
if (stopped) {
|
||||
if (!jsonFlag) {
|
||||
Log.succeed('Stopped the simulator session after setup failed.');
|
||||
}
|
||||
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId).catch(error => {
|
||||
Log.warn(
|
||||
`The session stopped, but ${SIMULATOR_DOTENV_FILE_NAME} could not be cleaned up: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
Log.warn(
|
||||
`Could not confirm cleanup. Run eas simulator:stop --id ${deviceRunSessionId} to stop the session.`
|
||||
);
|
||||
}
|
||||
sessionInterrupt.dispose();
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
stopped,
|
||||
setup: {
|
||||
config: imported.config.path,
|
||||
results:
|
||||
error instanceof ControlConfigExecutionError
|
||||
? serializeControlConfigResults(error.completed)
|
||||
: [],
|
||||
error: {
|
||||
...(error instanceof ControlConfigExecutionError
|
||||
? { step: error.step, action: error.action }
|
||||
: {}),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag || imported.config?.actions?.length) {
|
||||
// Keep the previous session's saved credentials until setup succeeds.
|
||||
await writeReadySimulatorEnvAsync();
|
||||
if (sessionInterrupt.signal.aborted) {
|
||||
await stopDeviceRunSessionAfterInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
projectDir,
|
||||
spinner: pollSpinner,
|
||||
sessionInterrupt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (jsonFlag) {
|
||||
sessionInterrupt.dispose();
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
...(setup ? { setup } : {}),
|
||||
remoteConfig: sanitizeRemoteConfigForJson(
|
||||
remoteConfig,
|
||||
deviceRunSessionPreviewUrl,
|
||||
flags['public-link'],
|
||||
{ frameAncestors, corsOrigins }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imported.config?.actions?.length) {
|
||||
startLocalEgress();
|
||||
}
|
||||
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
@@ -628,13 +846,14 @@ export default class Simulator extends EasCommand {
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
} finally {
|
||||
sessionInterrupt.signal.removeEventListener('abort', onSessionAbort);
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OperationResult, OperationResultSource } from '@urql/core';
|
||||
import { OperationContext, OperationResult, OperationResultSource } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -96,110 +96,115 @@ export const DeviceRunSessionQuery = {
|
||||
deviceRunSessionId: string
|
||||
): Promise<DeviceRunSessionByIdQuery['deviceRunSessions']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
name
|
||||
tags
|
||||
publicPreview
|
||||
status
|
||||
type
|
||||
platform
|
||||
createdAt
|
||||
startedAt
|
||||
finishedAt
|
||||
updatedAt
|
||||
app {
|
||||
id
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
artifacts {
|
||||
id
|
||||
name
|
||||
filename
|
||||
downloadUrl
|
||||
fileSizeBytes
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
remoteConfig {
|
||||
__typename
|
||||
... on AgentDeviceRunSessionRemoteConfig {
|
||||
agentDeviceRemoteSessionUrl
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
DeviceRunSessionQuery.byId(graphqlClient, deviceRunSessionId).toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
byId(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string,
|
||||
context: Partial<OperationContext> = {}
|
||||
): OperationResultSource<OperationResult<DeviceRunSessionByIdQuery>> {
|
||||
return graphqlClient.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
name
|
||||
tags
|
||||
publicPreview
|
||||
status
|
||||
type
|
||||
platform
|
||||
createdAt
|
||||
startedAt
|
||||
finishedAt
|
||||
updatedAt
|
||||
app {
|
||||
id
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
artifacts {
|
||||
id
|
||||
name
|
||||
filename
|
||||
downloadUrl
|
||||
fileSizeBytes
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
remoteConfig {
|
||||
__typename
|
||||
... on AgentDeviceRunSessionRemoteConfig {
|
||||
agentDeviceRemoteSessionUrl
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ requestPolicy: 'network-only', ...context }
|
||||
);
|
||||
},
|
||||
async listByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeControlActionAsync, prepareControlActionAsync } from '../controlActions';
|
||||
import { HostActionCatalog, HostActionConnection, HostActionResult } from '../hostActions';
|
||||
|
||||
const catalog: HostActionCatalog = {
|
||||
settings: { type: 'object' },
|
||||
install: { upload: 'uploadId', uploadAs: 'id' },
|
||||
media: { upload: 'path' },
|
||||
camera: { upload: 'target', uploadWhen: { source: 'file' }, keepUpload: true },
|
||||
screenshot: { output: 'path' },
|
||||
download: { output: 'base64' },
|
||||
};
|
||||
const ok = (stdout = ''): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
|
||||
describe('control action preparation and execution', () => {
|
||||
let tmpDir: string;
|
||||
let controller: AbortController;
|
||||
let result: HostActionResult;
|
||||
let connection: HostActionConnection;
|
||||
let requestAsync: jest.Mock<Promise<HostActionResult>, [string, Record<string, unknown>?]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'control-actions-'));
|
||||
controller = new AbortController();
|
||||
result = ok(' on\n');
|
||||
requestAsync = jest.fn(async (action: string, params: Record<string, unknown> = {}) => {
|
||||
if (action === 'upload.append') {
|
||||
return ok(`/remote/staging/${params.uploadId}\n`);
|
||||
}
|
||||
if (action === 'upload.remove') {
|
||||
return ok();
|
||||
}
|
||||
if (action === 'file.readBase64') {
|
||||
return ok(Buffer.from('download bytes').toString('base64'));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
connection = {
|
||||
udid: '90858C9D-BC34-4A0A-93D4-E4939A681A72',
|
||||
signal: controller.signal,
|
||||
requestAsync,
|
||||
close: () => {
|
||||
controller.abort(new Error('closed'));
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(tmpDir, 'fixture.json'), 'file bytes');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('copies typed and empty values without parsing stdout', async () => {
|
||||
const params = { enabled: false, count: 0, text: '', nested: { values: ['original'] } };
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params });
|
||||
params.text = 'changed';
|
||||
expect(plan.params).toEqual({
|
||||
enabled: false,
|
||||
count: 0,
|
||||
text: '',
|
||||
nested: { values: ['original'] },
|
||||
});
|
||||
result = ok(' {"value":0}\n');
|
||||
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '{"value":0}',
|
||||
file: null,
|
||||
});
|
||||
expect(requestAsync).toHaveBeenCalledWith('settings', plan.params);
|
||||
result = ok('');
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '',
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown actions and inapplicable output before execution', async () => {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, { action: 'toString', params: {} })
|
||||
).rejects.toThrow('Unknown simulator action');
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, { action: 'settings', params: {}, outputPath: './out' })
|
||||
).rejects.toThrow('does not return a file');
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves explicit local files and outputs against the supplied directory', async () => {
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: 'fixture.json',
|
||||
},
|
||||
{ localFileMode: 'explicit', baseDir: tmpDir }
|
||||
);
|
||||
expect(plan.localFile).toEqual({ param: 'uploadId', path: path.join(tmpDir, 'fixture.json') });
|
||||
const output = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath: 'result.json',
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
);
|
||||
expect(output.outputPath).toBe(path.join(tmpDir, 'result.json'));
|
||||
});
|
||||
|
||||
it('preserves an explicitly requested output directory when resolving relative paths', async () => {
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath: 'missing/',
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow('does not exist');
|
||||
});
|
||||
|
||||
it('rejects ambiguous or inapplicable explicit uploads', async () => {
|
||||
for (const action of [
|
||||
{ action: 'settings', params: {} },
|
||||
{ action: 'camera', params: { source: 'webcam' } },
|
||||
{ action: 'install', params: { uploadId: 'already-supplied' } },
|
||||
]) {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
...action,
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing, empty and directory inputs before execution', async () => {
|
||||
await fs.writeFile(path.join(tmpDir, 'empty'), '');
|
||||
for (const file of ['missing', 'empty', '.']) {
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses explicit host paths and does not infer uploads in explicit mode', async () => {
|
||||
const localFile = path.join(tmpDir, 'fixture.json');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'media',
|
||||
params: { path: localFile },
|
||||
})
|
||||
).localFile?.path
|
||||
).toBe(localFile);
|
||||
const remotePath = path.join(tmpDir, 'remote-only');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'media',
|
||||
params: { path: `host:${remotePath}` },
|
||||
})
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
await expect(
|
||||
prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'media',
|
||||
params: { path: 'missing-relative' },
|
||||
},
|
||||
{ baseDir: tmpDir }
|
||||
)
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(
|
||||
catalog,
|
||||
{
|
||||
action: 'media',
|
||||
params: { path: localFile },
|
||||
},
|
||||
{ localFileMode: 'explicit' }
|
||||
)
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'webcam', target: 'Built-in Camera' },
|
||||
})
|
||||
).localFile
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a direct relative upload against baseDir without rewriting caller parameters', async () => {
|
||||
const params = { path: './fixture.json' };
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{ action: 'media', params },
|
||||
{ baseDir: tmpDir }
|
||||
);
|
||||
expect(plan.localFile?.path).toBe(path.join(tmpDir, 'fixture.json'));
|
||||
await executeControlActionAsync(connection, plan);
|
||||
expect(requestAsync.mock.calls[1]).toEqual([
|
||||
'media',
|
||||
{ path: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}` },
|
||||
]);
|
||||
expect(params).toEqual({ path: './fixture.json' });
|
||||
});
|
||||
|
||||
(process.platform === 'win32' ? it.skip : it)(
|
||||
'rejects a named pipe before opening it for upload',
|
||||
async () => {
|
||||
const file = path.join(tmpDir, 'input.pipe');
|
||||
execFileSync('mkfifo', [file]);
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
})
|
||||
).rejects.toThrow('not a regular file');
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
(process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it)(
|
||||
'rejects a local file that cannot be read before upload',
|
||||
async () => {
|
||||
const file = path.join(tmpDir, 'fixture.json');
|
||||
await fs.chmod(file, 0);
|
||||
try {
|
||||
await expect(
|
||||
prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EACCES' });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(file, 0o600);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('substitutes an upload ID without changing the plan and removes staging after success', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await executeControlActionAsync(connection, plan);
|
||||
const uploadId = requestAsync.mock.calls[0][1]?.uploadId;
|
||||
expect(uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.json$/);
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('file bytes').toString('base64'), first: true },
|
||||
],
|
||||
['install', { uploadId }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
expect(plan.params).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a successful camera upload but cleans it after a failed action', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'file' },
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await executeControlActionAsync(connection, plan);
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual(['upload.append', 'camera']);
|
||||
expect(requestAsync.mock.calls[1][1]).toEqual({
|
||||
source: 'file',
|
||||
target: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}`,
|
||||
});
|
||||
requestAsync.mockClear();
|
||||
result = { stdout: '', stderr: 'camera unavailable', exitCode: 1 };
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow('camera unavailable');
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'camera',
|
||||
'upload.remove',
|
||||
]);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans staging if validation rejects real uploaded parameters', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'camera',
|
||||
params: { source: 'file' },
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
const validateParams = jest.fn(() => {
|
||||
throw new Error('invalid uploaded path');
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan, { validateParams })).rejects.toThrow(
|
||||
'invalid uploaded path'
|
||||
);
|
||||
expect(validateParams).toHaveBeenCalledWith({
|
||||
source: 'file',
|
||||
target: `/remote/staging/${requestAsync.mock.calls[0][1]?.uploadId}`,
|
||||
});
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a failed upload without running the action', async () => {
|
||||
requestAsync.mockImplementation(async action =>
|
||||
action === 'upload.append' ? { stdout: '', stderr: 'staging full', exitCode: 1 } : ok()
|
||||
);
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'install',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow('staging full');
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves an action output and returns the path instead of raw bytes', async () => {
|
||||
result = ok(Buffer.from('file content').toString('base64'));
|
||||
const outputPath = path.join(tmpDir, 'output');
|
||||
const plan = await prepareControlActionAsync(catalog, {
|
||||
action: 'download',
|
||||
params: {},
|
||||
outputPath,
|
||||
});
|
||||
await expect(executeControlActionAsync(connection, plan)).resolves.toEqual({
|
||||
output: '',
|
||||
file: outputPath,
|
||||
});
|
||||
expect(await fs.readFile(outputPath, 'utf8')).toBe('file content');
|
||||
});
|
||||
|
||||
it('removes staging if the output directory disappears after preparation', async () => {
|
||||
const outputDir = path.join(tmpDir, 'output');
|
||||
await fs.mkdir(outputDir);
|
||||
const plan = await prepareControlActionAsync(
|
||||
{
|
||||
snapshot: { upload: 'path', output: 'base64' },
|
||||
},
|
||||
{
|
||||
action: 'snapshot',
|
||||
params: {},
|
||||
file: path.join(tmpDir, 'fixture.json'),
|
||||
outputPath: path.join(outputDir, 'snapshot.png'),
|
||||
}
|
||||
);
|
||||
await fs.remove(outputDir);
|
||||
result = ok(Buffer.from('png').toString('base64'));
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'snapshot',
|
||||
'upload.remove',
|
||||
]);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ stdout: 'failure on stdout', stderr: '', exitCode: 1 }, 'failure on stdout'],
|
||||
[{ stdout: '', stderr: '', exitCode: 7 }, 'exit code 7'],
|
||||
])(
|
||||
'reports server failure details without closing a usable connection',
|
||||
async (failure, message) => {
|
||||
result = failure as HostActionResult;
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params: {} });
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toThrow(message);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not send work after a terminal connection loss', async () => {
|
||||
const plan = await prepareControlActionAsync(catalog, { action: 'settings', params: {} });
|
||||
const error = new Error('connection lost');
|
||||
controller.abort(error);
|
||||
await expect(executeControlActionAsync(connection, plan)).rejects.toBe(error);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeControlConfigAsync, prepareControlConfigAsync } from '../controlConfig';
|
||||
import { HostActionCatalog, HostActionConnection, HostActionResult } from '../hostActions';
|
||||
|
||||
const itWithChmod = process.platform === 'win32' || process.geteuid?.() === 0 ? it.skip : it;
|
||||
|
||||
const catalog: HostActionCatalog = {
|
||||
'keyboard.set': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, value: { enum: ['on', 'off'] } },
|
||||
required: ['udid', 'value'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
'file.push': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
uploadId: { type: 'string', pattern: '^[A-Za-z0-9._-]+$' },
|
||||
destination: { type: 'string' },
|
||||
},
|
||||
required: ['udid', 'uploadId', 'destination'],
|
||||
additionalProperties: false,
|
||||
upload: 'uploadId',
|
||||
uploadAs: 'id',
|
||||
},
|
||||
'camera.set': {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
source: { enum: ['file', 'webcam'] },
|
||||
path: { type: 'string' },
|
||||
},
|
||||
required: ['udid', 'source', 'path'],
|
||||
upload: 'path',
|
||||
uploadWhen: { source: 'file' },
|
||||
keepUpload: true,
|
||||
},
|
||||
screenshot: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' } },
|
||||
required: ['udid'],
|
||||
additionalProperties: false,
|
||||
output: 'base64',
|
||||
},
|
||||
echo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
enabled: { type: 'boolean' },
|
||||
count: { type: 'number' },
|
||||
items: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['udid'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('simulator control configuration', () => {
|
||||
let folder: string;
|
||||
let filePath: string;
|
||||
const ok = (stdout = ''): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
|
||||
const requestAsync = jest.fn(async (action: string): Promise<HostActionResult> => {
|
||||
if (action === 'upload.append') {
|
||||
return ok('/remote/staged.txt\n');
|
||||
}
|
||||
if (action === 'screenshot') {
|
||||
return ok(Buffer.from('png').toString('base64'));
|
||||
}
|
||||
return ok('done\n');
|
||||
});
|
||||
const connection: HostActionConnection = {
|
||||
requestAsync,
|
||||
close: jest.fn(),
|
||||
signal: new AbortController().signal,
|
||||
udid: 'owned-udid',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'control-config-'));
|
||||
filePath = path.join(folder, 'simulator.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(folder);
|
||||
});
|
||||
|
||||
async function writeConfig(actions: unknown[], rest = {}): Promise<void> {
|
||||
await fs.writeJson(filePath, { version: 1, actions, ...rest });
|
||||
}
|
||||
|
||||
it('preserves typed, omitted and empty values without changing the input', async () => {
|
||||
const params = { text: '', enabled: false, count: 0, items: ['first', 'second'] };
|
||||
await writeConfig([{ action: 'echo', params }, { action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
const results = await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
['echo', params],
|
||||
['echo', {}],
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ action: 'echo', output: 'done', file: null },
|
||||
{ action: 'echo', output: 'done', file: null },
|
||||
]);
|
||||
expect((await fs.readJson(filePath)).actions[0].params).toEqual(params);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: 2, actions: [{ action: 'echo' }] },
|
||||
{ version: 1, actions: [] },
|
||||
{ version: 1, actions: [{ action: 'echo', extra: true }] },
|
||||
{ version: 1, actions: [{ action: 'echo', params: [] }] },
|
||||
{ version: 1, actions: [{ action: 'echo', params: { udid: 'other-device' } }] },
|
||||
{ version: 1, actions: [{ action: 'echo' }], sessionId: 'other-session' },
|
||||
])('rejects an invalid config envelope: %j', async input => {
|
||||
await fs.writeJson(filePath, input);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports invalid JSON with its config path', async () => {
|
||||
await fs.writeFile(filePath, '{ invalid');
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
'simulator.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing config file', async () => {
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preflights later steps before running any action or upload', async () => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
{ action: 'keyboard.set', params: { value: 'invalid' } },
|
||||
]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*keyboard\.set/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unknown actions before any execution', async () => {
|
||||
await writeConfig([{ action: 'echo' }, { action: 'missing' }]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*missing/i
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an explicit output for path downloads before executing earlier steps', async () => {
|
||||
const withDownload: HostActionCatalog = {
|
||||
...catalog,
|
||||
'screenshot.capture': { ...catalog.screenshot, output: 'path' },
|
||||
};
|
||||
await writeConfig([{ action: 'echo' }, { action: 'screenshot.capture' }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(withDownload, filePath, connection.udid)
|
||||
).rejects.toThrow(/step 2.*screenshot\.capture.*output/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects mistyped parameters rather than coercing JSON', async () => {
|
||||
await writeConfig([{ action: 'echo', params: { count: '12' } }]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/count/i
|
||||
);
|
||||
});
|
||||
|
||||
it('uses JSON Schema 2020-12 and leaves schema defaults omitted', async () => {
|
||||
const withTuple: HostActionCatalog = {
|
||||
tuple: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
udid: { type: 'string' },
|
||||
value: {
|
||||
type: 'array',
|
||||
prefixItems: [{ type: 'number' }, { type: 'string' }],
|
||||
items: false,
|
||||
},
|
||||
enabled: { type: 'boolean', default: true },
|
||||
},
|
||||
required: ['udid', 'value'],
|
||||
},
|
||||
};
|
||||
await writeConfig([{ action: 'tuple', params: { value: [7, 'text'] } }]);
|
||||
const config = await prepareControlConfigAsync(withTuple, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('tuple', { value: [7, 'text'] });
|
||||
await writeConfig([{ action: 'tuple', params: { value: ['wrong', 'text'] } }]);
|
||||
await expect(prepareControlConfigAsync(withTuple, filePath, connection.udid)).rejects.toThrow(
|
||||
/params\/value\/0/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to execute a plan checked for another simulator', async () => {
|
||||
await writeConfig([{ action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, 'another-udid');
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toThrow(
|
||||
'simulator changed'
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves explicit local files relative to the config and substitutes upload IDs', async () => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('upload.append', {
|
||||
uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/),
|
||||
data: Buffer.from('content').toString('base64'),
|
||||
first: true,
|
||||
});
|
||||
const call = requestAsync.mock.calls.find(([action]) => action === 'file.push');
|
||||
expect(call).toEqual([
|
||||
'file.push',
|
||||
{ destination: 'data.txt', uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/) },
|
||||
]);
|
||||
expect(requestAsync).toHaveBeenLastCalledWith('upload.remove', {
|
||||
uploadId: expect.stringMatching(/^eas-cli-.+\.txt$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps remote paths unchanged even when a similarly named local file exists', async () => {
|
||||
const localPath = path.join(folder, 'fixture.txt');
|
||||
await fs.writeFile(localPath, 'local content');
|
||||
await writeConfig([{ action: 'camera.set', params: { source: 'file', path: localPath } }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync.mock.calls).toEqual([['camera.set', { source: 'file', path: localPath }]]);
|
||||
});
|
||||
|
||||
it('resolves output paths relative to the config', async () => {
|
||||
await writeConfig([{ action: 'screenshot', output: './screen.png' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
const results = await executeControlConfigAsync(connection, config);
|
||||
expect(await fs.readFile(path.join(folder, 'screen.png'), 'utf8')).toBe('png');
|
||||
expect(results[0].file).toBe(path.join(folder, 'screen.png'));
|
||||
});
|
||||
|
||||
itWithChmod.each(['existing file', 'new file', 'download directory'])(
|
||||
'rejects an unwritable %s before running earlier actions',
|
||||
async kind => {
|
||||
const destination = path.join(folder, 'read-only');
|
||||
const output = kind === 'new file' ? path.join(destination, 'screen.png') : destination;
|
||||
if (kind === 'existing file') {
|
||||
await fs.writeFile(destination, 'existing image');
|
||||
await fs.chmod(destination, 0o444);
|
||||
} else {
|
||||
await fs.mkdir(destination);
|
||||
await fs.chmod(destination, 0o555);
|
||||
}
|
||||
const action = kind === 'download directory' ? 'download' : 'screenshot';
|
||||
const withDownload: HostActionCatalog = {
|
||||
...catalog,
|
||||
download: { ...catalog.screenshot, output: 'path' },
|
||||
};
|
||||
try {
|
||||
await writeConfig([{ action: 'echo' }, { action, output }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(withDownload, filePath, connection.udid)
|
||||
).rejects.toThrow(/step 2.*(EACCES|permission)/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['app.install', 'media.add'])(
|
||||
'rejects an explicit file combined with another source for %s',
|
||||
async action => {
|
||||
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
|
||||
await writeConfig([{ action, params: { uploadId: 'old.ipa' }, file: './new.ipa' }]);
|
||||
await expect(
|
||||
prepareControlConfigAsync(realCatalog, filePath, connection.udid)
|
||||
).rejects.toThrow(/file.*source/i);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
itWithChmod(
|
||||
'rejects a new output in a write-only parent before running earlier actions',
|
||||
async () => {
|
||||
const destination = path.join(folder, 'write-only');
|
||||
await fs.mkdir(destination);
|
||||
await fs.chmod(destination, 0o222);
|
||||
try {
|
||||
await writeConfig([
|
||||
{ action: 'echo' },
|
||||
{ action: 'screenshot', output: path.join(destination, 'screen.png') },
|
||||
]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*(EACCES|permission)/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a denied output permission check before running earlier actions', async () => {
|
||||
await writeConfig([
|
||||
{ action: 'echo' },
|
||||
{ action: 'screenshot', output: path.join(folder, 'screen.png') },
|
||||
]);
|
||||
const access = jest.spyOn(fs, 'access').mockImplementationOnce(async () => {
|
||||
throw Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
||||
});
|
||||
try {
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
|
||||
/step 2.*Permission denied/i
|
||||
);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
access.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an explicit file as the only source of a union action', async () => {
|
||||
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
|
||||
await writeConfig([{ action: 'app.install', file: './new.ipa' }]);
|
||||
const config = await prepareControlConfigAsync(realCatalog, filePath, connection.udid);
|
||||
await executeControlConfigAsync(connection, config);
|
||||
expect(requestAsync).toHaveBeenCalledWith('app.install', { path: '/remote/staged.txt' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ action: 'echo', file: './fixture.txt' },
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './missing.txt' },
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: '.' },
|
||||
{ action: 'camera.set', params: { source: 'webcam', path: 'camera' }, file: './fixture.txt' },
|
||||
{
|
||||
action: 'file.push',
|
||||
params: { destination: 'data.txt', uploadId: 'already-staged' },
|
||||
file: './fixture.txt',
|
||||
},
|
||||
{ action: 'screenshot', output: './missing/screen.png' },
|
||||
])('rejects invalid file/output plans before execution: %j', async action => {
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([action]);
|
||||
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow();
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops on the first runtime error and reports completed steps', async () => {
|
||||
await writeConfig([
|
||||
{ action: 'echo', params: { text: 'first' } },
|
||||
{ action: 'keyboard.set', params: { value: 'off' } },
|
||||
{ action: 'echo', params: { text: 'third' } },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
requestAsync.mockResolvedValueOnce(ok('first')).mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: 'device stopped',
|
||||
exitCode: 1,
|
||||
});
|
||||
const onProgress = jest.fn();
|
||||
await expect(
|
||||
executeControlConfigAsync(connection, config, { onProgress })
|
||||
).rejects.toMatchObject({
|
||||
step: 2,
|
||||
action: 'keyboard.set',
|
||||
completed: [{ action: 'echo', output: 'first', file: null }],
|
||||
});
|
||||
expect(requestAsync.mock.calls).toEqual([
|
||||
['echo', { text: 'first' }],
|
||||
['keyboard.set', { value: 'off' }],
|
||||
]);
|
||||
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
|
||||
{ step: 1, total: 3, action: 'echo', status: 'running' },
|
||||
{
|
||||
step: 1,
|
||||
total: 3,
|
||||
action: 'echo',
|
||||
status: 'completed',
|
||||
result: { action: 'echo', output: 'first', file: null },
|
||||
},
|
||||
{ step: 2, total: 3, action: 'keyboard.set', status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves a non-Error failure without retrying the action', async () => {
|
||||
await writeConfig([{ action: 'echo' }]);
|
||||
const config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
requestAsync.mockRejectedValueOnce('connection lost');
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toMatchObject({
|
||||
step: 1,
|
||||
completed: [],
|
||||
message: expect.stringContaining('connection lost'),
|
||||
});
|
||||
expect(requestAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates the actual uploaded value before invoking an action', async () => {
|
||||
const strict: HostActionCatalog = {
|
||||
'file.push': {
|
||||
...catalog['file.push'],
|
||||
uploadAs: 'path',
|
||||
properties: {
|
||||
...catalog['file.push'].properties,
|
||||
uploadId: { type: 'string', pattern: '^/tmp/serve-sim-uploads/' },
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(folder, 'fixture.txt'), 'content');
|
||||
await writeConfig([
|
||||
{ action: 'file.push', params: { destination: 'data.txt' }, file: './fixture.txt' },
|
||||
]);
|
||||
const config = await prepareControlConfigAsync(strict, filePath, connection.udid);
|
||||
await expect(executeControlConfigAsync(connection, config)).rejects.toThrow(/uploadId/);
|
||||
expect(requestAsync.mock.calls.map(([name]) => name)).toEqual([
|
||||
'upload.append',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import Log from '../../log';
|
||||
import { promptControlActionAsync, runControlMenuAsync } from '../controlInteractive';
|
||||
import { ControlPromptCancelledError, promptControlValueAsync } from '../controlPrompts';
|
||||
import { HostActionCatalog } from '../hostActions';
|
||||
|
||||
jest.mock('../controlPrompts', () => ({
|
||||
...jest.requireActual('../controlPrompts'),
|
||||
promptControlValueAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../log');
|
||||
jest.unmock('prompts');
|
||||
|
||||
const prompt = jest.mocked(promptControlValueAsync);
|
||||
const catalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
function useTerminalPrompts(): { enterAsync(value: string): Promise<void>; output(): string } {
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode: jest.fn() });
|
||||
const output = Object.assign(new PassThrough(), { columns: 80 });
|
||||
let text = '';
|
||||
output.on('data', chunk => {
|
||||
text += String(chunk);
|
||||
});
|
||||
const actual = jest.requireActual<typeof import('../controlPrompts')>('../controlPrompts');
|
||||
prompt.mockImplementation(
|
||||
async (question, signal) =>
|
||||
await actual.promptControlValueAsync({ ...question, stdin: input, stdout: output }, signal)
|
||||
);
|
||||
return {
|
||||
async enterAsync(value) {
|
||||
input.write(`\u0007${value}\r`);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
},
|
||||
output: () => text,
|
||||
};
|
||||
}
|
||||
|
||||
describe('catalog-driven control prompts', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it('asks for enum values without hardcoding the action name', async () => {
|
||||
prompt.mockResolvedValueOnce('off');
|
||||
await expect(
|
||||
promptControlActionAsync('future.toggle', catalog['hardwareKeyboard.set'], signal)
|
||||
).resolves.toEqual({ action: 'future.toggle', params: { value: 'off' } });
|
||||
expect(prompt.mock.calls[0][0]).toMatchObject({
|
||||
type: 'select',
|
||||
choices: [
|
||||
{ title: 'on', value: 'on' },
|
||||
{ title: 'off', value: 'off' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the exposed path branch instead of asking for an upload ID', async () => {
|
||||
prompt.mockResolvedValueOnce('./My App.ipa');
|
||||
await expect(
|
||||
promptControlActionAsync('app.install', catalog['app.install'], signal)
|
||||
).resolves.toEqual({ action: 'app.install', params: { path: './My App.ipa' } });
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('selects a discriminator branch and omits other branch fields', async () => {
|
||||
prompt.mockResolvedValueOnce('placeholder');
|
||||
await expect(
|
||||
promptControlActionAsync('camera.switch', catalog['camera.switch'], signal)
|
||||
).resolves.toEqual({ action: 'camera.switch', params: { source: 'placeholder' } });
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves optional values omitted and preserves explicitly empty strings', async () => {
|
||||
const entry = {
|
||||
type: 'object',
|
||||
properties: { text: { type: 'string', default: 'server default' } },
|
||||
};
|
||||
prompt.mockResolvedValueOnce(false);
|
||||
await expect(promptControlActionAsync('clipboard.paste', entry, signal)).resolves.toEqual({
|
||||
action: 'clipboard.paste',
|
||||
params: {},
|
||||
});
|
||||
prompt.mockResolvedValueOnce(true).mockResolvedValueOnce('');
|
||||
await expect(promptControlActionAsync('clipboard.paste', entry, signal)).resolves.toEqual({
|
||||
action: 'clipboard.paste',
|
||||
params: { text: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads array values as JSON and prompts for file output before execution', async () => {
|
||||
const entry = {
|
||||
type: 'object',
|
||||
properties: { events: { type: 'array', items: { type: 'object' } } },
|
||||
required: ['events'],
|
||||
output: 'base64' as const,
|
||||
};
|
||||
prompt.mockResolvedValueOnce('[{"type":"begin"}]').mockResolvedValueOnce('./output.png');
|
||||
await expect(promptControlActionAsync('future.gesture', entry, signal)).resolves.toEqual({
|
||||
action: 'future.gesture',
|
||||
params: { events: [{ type: 'begin' }] },
|
||||
outputPath: './output.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a JSON object when branches cannot be selected safely', async () => {
|
||||
const entry = {
|
||||
anyOf: [
|
||||
{ properties: { a: { type: 'string' } }, required: ['a'] },
|
||||
{ properties: { b: { type: 'string' } }, required: ['b'] },
|
||||
],
|
||||
};
|
||||
prompt.mockResolvedValueOnce('{"b":"value"}');
|
||||
await expect(promptControlActionAsync('future.union', entry, signal)).resolves.toEqual({
|
||||
action: 'future.union',
|
||||
params: { b: 'value' },
|
||||
});
|
||||
expect(prompt.mock.calls[0][0].message).toContain('JSON');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ anyOf: [{ type: 'boolean' }, { type: 'null' }] }, 'false', false],
|
||||
[{ anyOf: [{ type: 'boolean' }, { type: 'null' }] }, 'null', null],
|
||||
[{ anyOf: [{ type: 'string' }, { type: 'object' }] }, '"hello"', 'hello'],
|
||||
])('accepts JSON values for mixed property unions %#', async (schema, input, expected) => {
|
||||
prompt.mockResolvedValueOnce(input);
|
||||
await expect(
|
||||
promptControlActionAsync(
|
||||
'future.union',
|
||||
{ type: 'object', properties: { value: schema }, required: ['value'] },
|
||||
signal
|
||||
)
|
||||
).resolves.toEqual({ action: 'future.union', params: { value: expected } });
|
||||
});
|
||||
|
||||
it('combines required fields from allOf and keeps optional defaults on the server', async () => {
|
||||
const entry = {
|
||||
type: 'object',
|
||||
properties: { precision: { type: 'integer', default: 4 } },
|
||||
allOf: [
|
||||
{ type: 'object', properties: { lat: { type: 'number' } }, required: ['lat'] },
|
||||
{ type: 'object', properties: { lng: { type: 'number' } }, required: ['lng'] },
|
||||
],
|
||||
args: ['lat', 'lng'],
|
||||
};
|
||||
prompt.mockResolvedValueOnce('37.5').mockResolvedValueOnce('-122').mockResolvedValueOnce(false);
|
||||
await expect(promptControlActionAsync('future.location', entry, signal)).resolves.toEqual({
|
||||
action: 'future.location',
|
||||
params: { lat: 37.5, lng: -122 },
|
||||
});
|
||||
expect(prompt.mock.calls[2][0]).toMatchObject({
|
||||
message: 'Set optional precision (default: 4)?',
|
||||
initial: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a boolean toggle and supplies required constants without another question', async () => {
|
||||
const entry = {
|
||||
type: 'object',
|
||||
properties: { enabled: { type: 'boolean' }, mode: { const: 'fixed' } },
|
||||
required: ['enabled', 'mode'],
|
||||
};
|
||||
prompt.mockResolvedValueOnce(false);
|
||||
await expect(promptControlActionAsync('future.enabled', entry, signal)).resolves.toEqual({
|
||||
action: 'future.enabled',
|
||||
params: { enabled: false, mode: 'fixed' },
|
||||
});
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(prompt.mock.calls[0][0]).toMatchObject({
|
||||
type: 'toggle',
|
||||
active: 'true',
|
||||
inactive: 'false',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts either a named pose or numeric angle for the existing scalar union', async () => {
|
||||
prompt.mockResolvedValueOnce('half').mockResolvedValueOnce('90');
|
||||
await expect(
|
||||
promptControlActionAsync('hinge.set', catalog['hinge.set'], signal)
|
||||
).resolves.toEqual({ action: 'hinge.set', params: { value: 'half' } });
|
||||
await expect(
|
||||
promptControlActionAsync('hinge.set', catalog['hinge.set'], signal)
|
||||
).resolves.toEqual({ action: 'hinge.set', params: { value: 90 } });
|
||||
});
|
||||
|
||||
it('uses a JSON object for nested alternatives it cannot guide safely', async () => {
|
||||
const entry = {
|
||||
allOf: [
|
||||
{ type: 'object', properties: { common: { type: 'string' } }, required: ['common'] },
|
||||
{
|
||||
anyOf: [
|
||||
{ properties: { file: { type: 'string' } } },
|
||||
{ properties: { url: { type: 'string' } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
prompt.mockResolvedValueOnce('{"common":"test","file":"./file.txt"}');
|
||||
await expect(promptControlActionAsync('future.nested', entry, signal)).resolves.toEqual({
|
||||
action: 'future.nested',
|
||||
params: { common: 'test', file: './file.txt' },
|
||||
});
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(prompt.mock.calls[0][0].message).toContain('JSON object');
|
||||
});
|
||||
|
||||
it('does not guess between branches that share the same discriminator value', async () => {
|
||||
const entry = {
|
||||
oneOf: [
|
||||
{ properties: { source: { const: 'file' }, path: { type: 'string' } } },
|
||||
{ properties: { source: { const: 'file' }, uploadId: { type: 'string' } } },
|
||||
],
|
||||
};
|
||||
prompt.mockResolvedValueOnce('{"source":"file","path":"./test"}');
|
||||
await expect(
|
||||
promptControlActionAsync('future.ambiguous', entry, signal)
|
||||
).resolves.toMatchObject({ params: { source: 'file', path: './test' } });
|
||||
expect(prompt.mock.calls[0][0].message).toContain('JSON object');
|
||||
});
|
||||
|
||||
it('keeps an invalid number in the real prompt until the user corrects it', async () => {
|
||||
const terminal = useTerminalPrompts();
|
||||
const result = promptControlActionAsync('location.set', catalog['location.set'], signal);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await terminal.enterAsync('not a number');
|
||||
expect(terminal.output()).toContain('Enter a valid number.');
|
||||
await terminal.enterAsync('37.5');
|
||||
await terminal.enterAsync('-122');
|
||||
await expect(result).resolves.toEqual({
|
||||
action: 'location.set',
|
||||
params: { lat: 37.5, lng: -122 },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires valid JSON with the right container type before accepting a complex field', async () => {
|
||||
const terminal = useTerminalPrompts();
|
||||
const entry = {
|
||||
type: 'object',
|
||||
properties: { events: { type: 'array' } },
|
||||
required: ['events'],
|
||||
};
|
||||
const result = promptControlActionAsync('future.gesture', entry, signal);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await terminal.enterAsync('[broken');
|
||||
await terminal.enterAsync('{}');
|
||||
expect(terminal.output()).toContain('Enter a JSON value matching this parameter.');
|
||||
await terminal.enterAsync('[{"type":"begin"}]');
|
||||
await expect(result).resolves.toMatchObject({ params: { events: [{ type: 'begin' }] } });
|
||||
});
|
||||
|
||||
it('rejects malformed and non-object fallback JSON before accepting corrected parameters', async () => {
|
||||
const terminal = useTerminalPrompts();
|
||||
const entry = {
|
||||
anyOf: [{ properties: { a: { type: 'string' } } }, { properties: { b: { type: 'string' } } }],
|
||||
};
|
||||
const result = promptControlActionAsync('future.fallback', entry, signal);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await terminal.enterAsync('invalid');
|
||||
await terminal.enterAsync('[]');
|
||||
expect(terminal.output()).toContain('Enter a JSON object.');
|
||||
await terminal.enterAsync('{"b":"corrected"}');
|
||||
await expect(result).resolves.toMatchObject({ params: { b: 'corrected' } });
|
||||
});
|
||||
|
||||
it('requires an output destination before returning a file action', async () => {
|
||||
const terminal = useTerminalPrompts();
|
||||
const result = promptControlActionAsync(
|
||||
'future.screenshot',
|
||||
{ type: 'object', output: 'base64' },
|
||||
signal
|
||||
);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await terminal.enterAsync(' ');
|
||||
expect(terminal.output()).toContain('Enter a local output path.');
|
||||
await terminal.enterAsync('./image.png');
|
||||
await expect(result).resolves.toEqual({
|
||||
action: 'future.screenshot',
|
||||
params: {},
|
||||
outputPath: './image.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive control loop', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it('searches friendly labels, action names and descriptions without losing catalog entries', async () => {
|
||||
prompt.mockResolvedValueOnce({ kind: 'exit' });
|
||||
await runControlMenuAsync({
|
||||
catalog: {
|
||||
'hardwareKeyboard.set': { description: 'Connect the physical keyboard' },
|
||||
'future.newAction': { description: 'A newly discovered capability' },
|
||||
},
|
||||
signal,
|
||||
runActionAsync: jest.fn(),
|
||||
});
|
||||
const question = prompt.mock.calls[0][0];
|
||||
const suggest = question.suggest as (
|
||||
input: string,
|
||||
choices: unknown[]
|
||||
) => Promise<{ value: unknown }[]>;
|
||||
expect((await suggest('HARDWARE keyboard', [])).map(choice => choice.value)).toEqual([
|
||||
{ kind: 'action', name: 'hardwareKeyboard.set' },
|
||||
]);
|
||||
expect((await suggest('future.newAction', [])).map(choice => choice.value)).toEqual([
|
||||
{ kind: 'action', name: 'future.newAction' },
|
||||
]);
|
||||
expect((await suggest('newly discovered', [])).map(choice => choice.value)).toEqual([
|
||||
{ kind: 'action', name: 'future.newAction' },
|
||||
]);
|
||||
expect(await suggest('no match', [])).toEqual([]);
|
||||
expect(await suggest('', [])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('runs one selection at a time and returns to the picker until Exit', async () => {
|
||||
const runActionAsync = jest.fn().mockResolvedValue(undefined);
|
||||
prompt
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'hardwareKeyboard.set' })
|
||||
.mockResolvedValueOnce('on')
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'hardwareKeyboard.set' })
|
||||
.mockResolvedValueOnce('off')
|
||||
.mockResolvedValueOnce({ kind: 'exit' });
|
||||
await runControlMenuAsync({ catalog, signal, runActionAsync });
|
||||
expect(runActionAsync.mock.calls).toEqual([
|
||||
[{ action: 'hardwareKeyboard.set', params: { value: 'on' } }],
|
||||
[{ action: 'hardwareKeyboard.set', params: { value: 'off' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns to the picker after a normal action error without replay', async () => {
|
||||
const runActionAsync = jest.fn().mockRejectedValue(new Error('Permission denied'));
|
||||
prompt
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'future.action' })
|
||||
.mockResolvedValueOnce({ kind: 'exit' });
|
||||
await runControlMenuAsync({
|
||||
catalog: { 'future.action': { type: 'object' } },
|
||||
signal,
|
||||
runActionAsync,
|
||||
});
|
||||
expect(runActionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(Log.error).toHaveBeenCalledWith('Permission denied');
|
||||
});
|
||||
|
||||
it('does not open another picker while an action is running', async () => {
|
||||
let complete!: () => void;
|
||||
const runActionAsync = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
complete = resolve;
|
||||
})
|
||||
);
|
||||
prompt
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'future.action' })
|
||||
.mockResolvedValueOnce({ kind: 'exit' });
|
||||
const running = runControlMenuAsync({
|
||||
catalog: { 'future.action': { type: 'object' } },
|
||||
signal,
|
||||
runActionAsync,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runActionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
complete();
|
||||
await running;
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops after ambiguous transport loss and never opens the next picker', async () => {
|
||||
const controller = new AbortController();
|
||||
const error = new Error('The action may still have run');
|
||||
const runActionAsync = jest.fn(async () => {
|
||||
controller.abort(error);
|
||||
throw error;
|
||||
});
|
||||
prompt.mockResolvedValueOnce({ kind: 'action', name: 'future.action' });
|
||||
await expect(
|
||||
runControlMenuAsync({
|
||||
catalog: { 'future.action': { type: 'object' } },
|
||||
signal: controller.signal,
|
||||
runActionAsync,
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(runActionAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unwinds a user cancellation', async () => {
|
||||
prompt.mockRejectedValueOnce(new ControlPromptCancelledError());
|
||||
await expect(
|
||||
runControlMenuAsync({ catalog, signal, runActionAsync: jest.fn() })
|
||||
).rejects.toBeInstanceOf(ControlPromptCancelledError);
|
||||
});
|
||||
|
||||
it('keeps the menu open when autocomplete has no matching selection', async () => {
|
||||
const runActionAsync = jest.fn();
|
||||
prompt.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ kind: 'exit' });
|
||||
await expect(runControlMenuAsync({ catalog, signal, runActionAsync })).resolves.toBeUndefined();
|
||||
expect(runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('runs a different action after an ordinary rejection', async () => {
|
||||
const runActionAsync = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
prompt
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'first' })
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'second' })
|
||||
.mockResolvedValueOnce({ kind: 'exit' });
|
||||
await runControlMenuAsync({
|
||||
catalog: { first: { type: 'object' }, second: { type: 'object' } },
|
||||
signal,
|
||||
runActionAsync,
|
||||
});
|
||||
expect(runActionAsync.mock.calls).toEqual([
|
||||
[{ action: 'first', params: {} }],
|
||||
[{ action: 'second', params: {} }],
|
||||
]);
|
||||
expect(Log.error).toHaveBeenCalledWith('Permission denied');
|
||||
});
|
||||
|
||||
it('does not send an action when Ctrl-C cancels its parameter prompt', async () => {
|
||||
const runActionAsync = jest.fn();
|
||||
prompt
|
||||
.mockResolvedValueOnce({ kind: 'action', name: 'hardwareKeyboard.set' })
|
||||
.mockRejectedValueOnce(new ControlPromptCancelledError());
|
||||
await expect(runControlMenuAsync({ catalog, signal, runActionAsync })).rejects.toBeInstanceOf(
|
||||
ControlPromptCancelledError
|
||||
);
|
||||
expect(runActionAsync).not.toHaveBeenCalled();
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not open any prompt if initialization already lost the session', async () => {
|
||||
const error = new Error('Already disconnected');
|
||||
await expect(
|
||||
runControlMenuAsync({ catalog, signal: AbortSignal.abort(error), runActionAsync: jest.fn() })
|
||||
).rejects.toBe(error);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ControlPromptCancelledError, promptControlValueAsync } from '../controlPrompts';
|
||||
|
||||
jest.unmock('prompts');
|
||||
|
||||
function terminal(): {
|
||||
input: PassThrough & { isTTY: boolean; setRawMode: jest.Mock };
|
||||
output: PassThrough & { columns: number };
|
||||
} {
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode: jest.fn() });
|
||||
const output = Object.assign(new PassThrough(), { columns: 80 });
|
||||
output.resume();
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
describe('abortable simulator control prompts', () => {
|
||||
it('aborts the real idle prompt and restores the terminal', async () => {
|
||||
const { input, output } = terminal();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('Connection lost');
|
||||
const result = promptControlValueAsync(
|
||||
{ type: 'text', message: 'Value', stdin: input, stdout: output },
|
||||
controller.signal
|
||||
);
|
||||
controller.abort(reason);
|
||||
await expect(result).rejects.toBe(reason);
|
||||
expect(input.setRawMode.mock.calls).toEqual([[true], [false]]);
|
||||
expect(input.listenerCount('keypress')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles cancellation during the first constructor render', async () => {
|
||||
const { input, output } = terminal();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('Session ended');
|
||||
await expect(
|
||||
promptControlValueAsync(
|
||||
{
|
||||
type: 'select',
|
||||
message: 'Action',
|
||||
choices: [{ title: 'Exit', value: null }],
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
onRender() {
|
||||
controller.abort(reason);
|
||||
},
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toBe(reason);
|
||||
expect(input.listenerCount('keypress')).toBe(0);
|
||||
expect(input.setRawMode).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('never opens a prompt for an already lost connection', async () => {
|
||||
const { input, output } = terminal();
|
||||
const signal = AbortSignal.abort(new Error('Already closed'));
|
||||
await expect(
|
||||
promptControlValueAsync(
|
||||
{ type: 'text', message: 'Value', stdin: input, stdout: output },
|
||||
signal
|
||||
)
|
||||
).rejects.toThrow('Already closed');
|
||||
expect(input.setRawMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unwinds Ctrl-C instead of exiting the process', async () => {
|
||||
const { input, output } = terminal();
|
||||
const result = promptControlValueAsync(
|
||||
{ type: 'text', message: 'Value', stdin: input, stdout: output },
|
||||
new AbortController().signal
|
||||
);
|
||||
input.write('\u0003');
|
||||
await expect(result).rejects.toBeInstanceOf(ControlPromptCancelledError);
|
||||
expect(input.listenerCount('keypress')).toBe(0);
|
||||
expect(input.setRawMode).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('preserves an explicitly empty string', async () => {
|
||||
const { input, output } = terminal();
|
||||
const result = promptControlValueAsync(
|
||||
{ type: 'text', message: 'Value', stdin: input, stdout: output },
|
||||
new AbortController().signal
|
||||
);
|
||||
input.write('\r');
|
||||
await expect(result).resolves.toBe('');
|
||||
expect(input.listenerCount('keypress')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { createClient, fetchExchange } from '@urql/core';
|
||||
import { getEventListeners } from 'node:events';
|
||||
|
||||
import { DeviceRunSessionStatus, JobRunStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ControlSessionEndedError, watchControlSession } from '../controlSession';
|
||||
|
||||
type SessionStatus = {
|
||||
status: DeviceRunSessionStatus;
|
||||
turtleJobRun?: { id: string; status: JobRunStatus } | null;
|
||||
};
|
||||
|
||||
type StatusSource = ReturnType<typeof DeviceRunSessionQuery.byId>;
|
||||
type StatusObserver = Parameters<StatusSource['subscribe']>[0];
|
||||
type StatusResult = Parameters<StatusObserver>[0];
|
||||
|
||||
type StatusRequest = {
|
||||
signal: AbortSignal;
|
||||
respond: (body: unknown) => void;
|
||||
fail: (error: Error) => void;
|
||||
};
|
||||
|
||||
function createStatusClient(): {
|
||||
client: ReturnType<typeof createClient>;
|
||||
fetch: jest.Mock<
|
||||
Promise<Response>,
|
||||
[url: Parameters<typeof globalThis.fetch>[0], options?: RequestInit]
|
||||
>;
|
||||
requests: StatusRequest[];
|
||||
activeRequests(): number;
|
||||
} {
|
||||
const requests: StatusRequest[] = [];
|
||||
let activeRequests = 0;
|
||||
const fetch = jest.fn((_url: Parameters<typeof globalThis.fetch>[0], options?: RequestInit) => {
|
||||
const signal = options!.signal!;
|
||||
activeRequests++;
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let finished = false;
|
||||
const finish = (): boolean => {
|
||||
if (finished) {
|
||||
return false;
|
||||
}
|
||||
finished = true;
|
||||
activeRequests--;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
return true;
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
if (finish()) {
|
||||
reject(new Error('Request aborted'));
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
requests.push({
|
||||
signal,
|
||||
respond: body => {
|
||||
if (finish()) {
|
||||
resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
fail: error => {
|
||||
if (finish()) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
const client = createClient({
|
||||
url: 'https://example.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetch,
|
||||
fetchOptions: () => ({ headers: { authorization: 'Bearer session-token' } }),
|
||||
});
|
||||
return { client, fetch, requests, activeRequests: () => activeRequests };
|
||||
}
|
||||
|
||||
function sessionResponse(status: SessionStatus): unknown {
|
||||
return { data: { deviceRunSessions: { byId: { id: 'session-id', ...status } } } };
|
||||
}
|
||||
|
||||
describe(watchControlSession, () => {
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
dispose();
|
||||
}
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function watch(
|
||||
client: ReturnType<typeof createStatusClient>['client'],
|
||||
options: Parameters<typeof watchControlSession>[2] = {
|
||||
signal: new AbortController().signal,
|
||||
}
|
||||
): ReturnType<typeof watchControlSession> {
|
||||
const watcher = watchControlSession(client, 'session-id', options);
|
||||
disposers.push(watcher.dispose);
|
||||
return watcher;
|
||||
}
|
||||
|
||||
it('polls serially after each completed request and preserves authentication', async () => {
|
||||
const observed = createStatusClient();
|
||||
const query = jest.spyOn(observed.client, 'query');
|
||||
watch(observed.client);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(4999);
|
||||
expect(observed.fetch).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ deviceRunSessionId: 'session-id' },
|
||||
{ requestPolicy: 'network-only', noRetry: true }
|
||||
);
|
||||
expect(observed.fetch.mock.calls[0][1]?.headers).toEqual(
|
||||
expect.objectContaining({ authorization: 'Bearer session-token' })
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(observed.activeRequests()).toBe(1);
|
||||
|
||||
observed.requests[0].respond(sessionResponse({ status: DeviceRunSessionStatus.InProgress }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
await jest.advanceTimersByTimeAsync(4999);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(observed.activeRequests()).toBe(1);
|
||||
});
|
||||
|
||||
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
|
||||
'ends control when an idle session becomes %s',
|
||||
async status => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].respond(sessionResponse({ status }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(true);
|
||||
expect(watcher.signal.reason).toBeInstanceOf(ControlSessionEndedError);
|
||||
expect(watcher.signal.reason.message).toContain('session-id');
|
||||
expect(watcher.signal.reason.failed).toBe(status === DeviceRunSessionStatus.Errored);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([JobRunStatus.Canceled, JobRunStatus.Finished, JobRunStatus.Errored])(
|
||||
'ends control when the worker job becomes %s before the session status catches up',
|
||||
async status => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].respond(
|
||||
sessionResponse({
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
turtleJobRun: { id: 'job-id', status },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(watcher.signal.reason).toBeInstanceOf(ControlSessionEndedError);
|
||||
expect(watcher.signal.reason.failed).toBe(status === JobRunStatus.Errored);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps watching a session without a job and a job whose cancellation is pending', async () => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].respond(
|
||||
sessionResponse({ status: DeviceRunSessionStatus.InProgress, turtleJobRun: null })
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[1].respond(
|
||||
sessionResponse({
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
turtleJobRun: { id: 'job-id', status: JobRunStatus.PendingCancel },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('logs network and GraphQL failures, then retries without ending control', async () => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].fail(new Error('Connection reset'));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[1].respond({ errors: [{ message: 'Temporarily unavailable' }] });
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(Log.debug).toHaveBeenCalledTimes(2);
|
||||
expect(observed.activeRequests()).toBe(1);
|
||||
observed.requests[2].respond(sessionResponse({ status: DeviceRunSessionStatus.Stopped }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(watcher.signal.reason).toBeInstanceOf(ControlSessionEndedError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ data: {} },
|
||||
{ data: { deviceRunSessions: null } },
|
||||
{ data: { deviceRunSessions: { byId: null } } },
|
||||
])('retries an empty session response %j without treating it as ended', async body => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].respond(body);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(Log.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session-id'),
|
||||
expect.objectContaining({ message: 'Session status response was empty.' })
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(2);
|
||||
observed.requests[1].respond(sessionResponse({ status: DeviceRunSessionStatus.Stopped }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(watcher.signal.reason).toBeInstanceOf(ControlSessionEndedError);
|
||||
});
|
||||
|
||||
it('waits for a successful response when GraphQL returns partial terminal data with an error', async () => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].respond({
|
||||
...(sessionResponse({ status: DeviceRunSessionStatus.Stopped }) as object),
|
||||
errors: [{ message: 'Status temporarily unavailable', extensions: { isTransient: true } }],
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(Log.debug).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[1].respond(sessionResponse({ status: DeviceRunSessionStatus.InProgress }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores stale and incomplete terminal results until the source confirms a final result', async () => {
|
||||
const observed = createStatusClient();
|
||||
const unsubscribe = jest.fn();
|
||||
let emit: StatusObserver | undefined;
|
||||
const subscribe = jest.fn((next: StatusObserver) => {
|
||||
emit = next;
|
||||
return { unsubscribe };
|
||||
});
|
||||
jest
|
||||
.spyOn(DeviceRunSessionQuery, 'byId')
|
||||
.mockReturnValue({ subscribe } as unknown as StatusSource);
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
const terminal = sessionResponse({ status: DeviceRunSessionStatus.Stopped }) as StatusResult;
|
||||
emit!({ ...terminal, stale: true, hasNext: false });
|
||||
emit!({ ...terminal, stale: false, hasNext: true });
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
emit!({ ...terminal, stale: false, hasNext: false });
|
||||
expect(watcher.signal.reason).toBeInstanceOf(ControlSessionEndedError);
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(Log.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.Stopped])(
|
||||
'unsubscribes a synchronously emitted %s response after subscribe returns its handle',
|
||||
async status => {
|
||||
const observed = createStatusClient();
|
||||
const unsubscribe = jest.fn();
|
||||
const subscribe = jest.fn((next: StatusObserver) => {
|
||||
next({ ...(sessionResponse({ status }) as StatusResult), stale: false, hasNext: false });
|
||||
return { unsubscribe };
|
||||
});
|
||||
jest
|
||||
.spyOn(DeviceRunSessionQuery, 'byId')
|
||||
.mockReturnValue({ subscribe } as unknown as StatusSource);
|
||||
const controller = new AbortController();
|
||||
const watcher = watch(observed.client, { signal: controller.signal });
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(watcher.signal.aborted).toBe(status === DeviceRunSessionStatus.Stopped);
|
||||
expect(jest.getTimerCount()).toBe(status === DeviceRunSessionStatus.Stopped ? 0 : 1);
|
||||
watcher.dispose();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
|
||||
expect(observed.fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('recovers from a synchronous query setup failure without leaving its deadline running', async () => {
|
||||
const observed = createStatusClient();
|
||||
const failure = new Error('Could not initialize status request');
|
||||
jest.spyOn(DeviceRunSessionQuery, 'byId').mockImplementationOnce(() => {
|
||||
throw failure;
|
||||
});
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('session-id'), failure);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
expect(observed.fetch).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
observed.requests[0].respond(sessionResponse({ status: DeviceRunSessionStatus.InProgress }));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('unsubscribes and aborts the fetch at the request deadline before retrying', async () => {
|
||||
const observed = createStatusClient();
|
||||
const watcher = watch(observed.client, {
|
||||
signal: new AbortController().signal,
|
||||
pollIntervalMs: 100,
|
||||
requestTimeoutMs: 200,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
expect(observed.activeRequests()).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(199);
|
||||
expect(observed.requests[0].signal.aborted).toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(observed.requests[0].signal.aborted).toBe(true);
|
||||
expect(observed.activeRequests()).toBe(0);
|
||||
expect(watcher.signal.aborted).toBe(false);
|
||||
expect(Log.debug).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(observed.activeRequests()).toBe(1);
|
||||
});
|
||||
|
||||
it('disposes an in-flight fetch and all timers without sending a stop mutation', async () => {
|
||||
const observed = createStatusClient();
|
||||
const mutation = jest.spyOn(observed.client, 'mutation');
|
||||
const watcher = watch(observed.client);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
watcher.dispose();
|
||||
watcher.dispose();
|
||||
|
||||
expect(observed.requests[0].signal.aborted).toBe(true);
|
||||
expect(observed.activeRequests()).toBe(0);
|
||||
expect(watcher.signal.aborted).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates parent cancellation and tears down an in-flight request', async () => {
|
||||
const observed = createStatusClient();
|
||||
const controller = new AbortController();
|
||||
const watcher = watch(observed.client, { signal: controller.signal });
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
const reason = new Error('Control connection lost');
|
||||
controller.abort(reason);
|
||||
|
||||
expect(watcher.signal.reason).toBe(reason);
|
||||
expect(observed.requests[0].signal.aborted).toBe(true);
|
||||
expect(observed.activeRequests()).toBe(0);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(Log.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears an idle poll and never polls an already canceled parent', async () => {
|
||||
const observed = createStatusClient();
|
||||
const controller = new AbortController();
|
||||
const watcher = watch(observed.client, { signal: controller.signal });
|
||||
controller.abort();
|
||||
expect(watcher.signal.reason).toBe(controller.signal.reason);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
const alreadyCanceled = watch(observed.client, { signal: controller.signal });
|
||||
expect(alreadyCanceled.signal.reason).toBe(controller.signal.reason);
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(observed.fetch).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('disposes an idle retry and removes its parent listener before the next poll', async () => {
|
||||
const observed = createStatusClient();
|
||||
const controller = new AbortController();
|
||||
const watcher = watch(observed.client, { signal: controller.signal });
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
observed.requests[0].fail(new Error('Connection reset'));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
watcher.dispose();
|
||||
|
||||
expect(watcher.signal.aborted).toBe(true);
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(observed.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves the existing byIdAsync result and default query context', async () => {
|
||||
const observed = createStatusClient();
|
||||
const query = jest.spyOn(observed.client, 'query');
|
||||
const result = DeviceRunSessionQuery.byIdAsync(observed.client, 'session-id');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
observed.requests[0].respond(sessionResponse({ status: DeviceRunSessionStatus.InProgress }));
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
id: 'session-id',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
});
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ deviceRunSessionId: 'session-id' },
|
||||
{ requestPolicy: 'network-only' }
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves byIdAsync rejection for a GraphQL error', async () => {
|
||||
const observed = createStatusClient();
|
||||
const result = DeviceRunSessionQuery.byIdAsync(observed.client, 'session-id');
|
||||
const rejected = expect(result).rejects.toThrow('Permission denied');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
observed.requests[0].respond({ errors: [{ message: 'Permission denied' }] });
|
||||
await rejected;
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import { getEventListeners, once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as fetchModule from '../../fetch';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
@@ -173,6 +174,108 @@ describe(formatHostActionUsage, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchHostActionDeviceAsync, () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('selects the connected device from the authenticated metadata catalog', async () => {
|
||||
const fetch = jest.spyOn(fetchModule, 'default').mockResolvedValueOnce(
|
||||
new fetchModule.Response(
|
||||
JSON.stringify({
|
||||
devices: [
|
||||
{ device: 'other-device', name: 'Other Simulator' },
|
||||
{ device: UDID, name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync(
|
||||
{ apiUrl: 'https://sim.example.test/preview/', token: TOKEN },
|
||||
UDID
|
||||
)
|
||||
).resolves.toEqual({
|
||||
udid: UDID,
|
||||
name: 'iPhone 17 Pro',
|
||||
runtime: 'iOS-26-4',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://sim.example.test/preview/grid/api/catalog',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Error('Metadata request failed'),
|
||||
new fetchModule.RequestError(
|
||||
'Request failed: 404 (Not Found)',
|
||||
new fetchModule.Response(undefined, { status: 404 })
|
||||
),
|
||||
Object.assign(new Error('Request timed out'), { name: 'AbortError' }),
|
||||
])('keeps the connected device ID when metadata fails: %s', async error => {
|
||||
jest.spyOn(fetchModule, 'default').mockRejectedValueOnce(error);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
});
|
||||
|
||||
it.each(['not JSON', JSON.stringify({ devices: null })])(
|
||||
'keeps the connected device ID when metadata is invalid: %s',
|
||||
async body => {
|
||||
jest.spyOn(fetchModule, 'default').mockResolvedValueOnce(new fetchModule.Response(body));
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects insecure metadata URLs before sending the session token', async () => {
|
||||
const fetch = jest.spyOn(fetchModule, 'default');
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'http://sim.example.test', token: TOKEN }, UDID)
|
||||
).rejects.toThrow('Simulator actions require HTTPS');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a device catalog to take more than three seconds during cold startup', async () => {
|
||||
let responseTimer: NodeJS.Timeout | undefined;
|
||||
const server = http.createServer((_request, response) => {
|
||||
responseTimer = setTimeout(() => {
|
||||
response.end(JSON.stringify({ devices: [{ device: UDID, name: 'iPhone 17 Pro' }] }));
|
||||
}, 3500);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address() as AddressInfo;
|
||||
try {
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: `http://127.0.0.1:${port}`, token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID, name: 'iPhone 17 Pro' });
|
||||
} finally {
|
||||
clearTimeout(responseTimer);
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not borrow metadata from another device', async () => {
|
||||
jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockResolvedValueOnce(
|
||||
new fetchModule.Response(
|
||||
JSON.stringify({ devices: [{ device: 'other-device', name: 'Other Simulator' }] })
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
fetchHostActionDeviceAsync({ apiUrl: 'https://sim.example.test', token: TOKEN }, UDID)
|
||||
).resolves.toEqual({ udid: UDID });
|
||||
});
|
||||
});
|
||||
|
||||
describe('host action transport policy', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
@@ -223,6 +326,7 @@ describe('host action connection', () => {
|
||||
let received: { id: number; action: string; params: Record<string, unknown> }[];
|
||||
let requests: (string | undefined)[];
|
||||
let apiBody: string;
|
||||
let apiStatus: number;
|
||||
let uploads: Map<string, Buffer>;
|
||||
let reply: (message: {
|
||||
id: number;
|
||||
@@ -232,6 +336,8 @@ describe('host action connection', () => {
|
||||
let tmpDir: string;
|
||||
let connection: HostActionConnection | undefined;
|
||||
let closeBeforeReady: boolean;
|
||||
let sendReady: boolean;
|
||||
let refuseUpgrade: boolean;
|
||||
|
||||
function serveSim(message: {
|
||||
id: number;
|
||||
@@ -266,13 +372,16 @@ describe('host action connection', () => {
|
||||
requests = [];
|
||||
uploads = new Map();
|
||||
apiBody = JSON.stringify({ device: UDID });
|
||||
apiStatus = 200;
|
||||
reply = serveSim;
|
||||
closeBeforeReady = false;
|
||||
sendReady = true;
|
||||
refuseUpgrade = false;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push(req.url);
|
||||
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.writeHead(apiStatus, { 'Content-Type': 'application/json' });
|
||||
res.end(apiBody);
|
||||
return;
|
||||
}
|
||||
@@ -282,6 +391,7 @@ describe('host action connection', () => {
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
verifyClient: () => !refuseUpgrade,
|
||||
handleProtocols: offered =>
|
||||
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
|
||||
});
|
||||
@@ -290,7 +400,9 @@ describe('host action connection', () => {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
if (sendReady) {
|
||||
ws.send(JSON.stringify({ ready: true }));
|
||||
}
|
||||
ws.on('message', data => {
|
||||
const message = JSON.parse(data.toString());
|
||||
received.push(message);
|
||||
@@ -305,6 +417,51 @@ describe('host action connection', () => {
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
it('does not open a connection when already canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('canceled before setup');
|
||||
controller.abort(reason);
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl, token: TOKEN }, { signal: controller.signal })
|
||||
).rejects.toBe(reason);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancels an upgrade waiting for readiness and releases the abort listener', async () => {
|
||||
sendReady = false;
|
||||
const controller = new AbortController();
|
||||
const accepted = once(wss, 'connection');
|
||||
const pending = connectHostActionsAsync(
|
||||
{ apiUrl, token: TOKEN },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
const [socket] = await accepted;
|
||||
const closed = once(socket, 'close');
|
||||
const reason = new Error('canceled during setup');
|
||||
const rejected = expect(pending).rejects.toBe(reason);
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancels a pending action on an established connection', async () => {
|
||||
const controller = new AbortController();
|
||||
connection = await connectHostActionsAsync(
|
||||
{ apiUrl, token: TOKEN },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
reply = () => null;
|
||||
const receivedRequest = once([...wss.clients][0], 'message');
|
||||
const pending = connection.requestAsync('appearance.get');
|
||||
const reason = new Error('canceled during action');
|
||||
const rejected = expect(pending).rejects.toBe(reason);
|
||||
await receivedRequest;
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
expect(connection.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connection?.close();
|
||||
connection = undefined;
|
||||
@@ -324,6 +481,8 @@ describe('host action connection', () => {
|
||||
|
||||
it('sends several requests over one connection with the simulator udid', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
expect(hostActions.udid).toBe(UDID);
|
||||
expect(hostActions.signal.aborted).toBe(false);
|
||||
|
||||
const results = await Promise.all([
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
@@ -387,6 +546,69 @@ describe('host action connection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a timeout terminal, rejects every pending request and never sends another', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync(50);
|
||||
const requests = [
|
||||
hostActions.requestAsync('memoryWarning'),
|
||||
hostActions.requestAsync('hardwareKeyboard.get'),
|
||||
];
|
||||
const results = await Promise.allSettled(requests);
|
||||
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
expect(results).toEqual([
|
||||
{ status: 'rejected', reason: hostActions.signal.reason },
|
||||
{ status: 'rejected', reason: hostActions.signal.reason },
|
||||
]);
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(hostActions.signal.reason);
|
||||
expect(received).toHaveLength(2);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect([...wss.clients].every(client => client.readyState !== 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts when the server closes an idle connection', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const aborted = once(hostActions.signal, 'abort');
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await aborted;
|
||||
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(hostActions.signal.reason);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts and immediately rejects pending work when its owner closes it', async () => {
|
||||
reply = () => null;
|
||||
const hostActions = await connectAsync();
|
||||
const request = hostActions.requestAsync('memoryWarning');
|
||||
const rejected = expect(request).rejects.toThrow('closed');
|
||||
hostActions.close();
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('makes a transport write failure terminal without retrying the request', async () => {
|
||||
const hostActions = await connectAsync();
|
||||
const failure = new Error('socket write failed');
|
||||
const send = jest
|
||||
.spyOn(WebSocket.prototype, 'send')
|
||||
.mockImplementation((_data, callback: unknown) => {
|
||||
if (typeof callback === 'function') {
|
||||
callback(failure);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(failure);
|
||||
expect(hostActions.signal.aborted).toBe(true);
|
||||
await expect(hostActions.requestAsync('memoryWarning')).rejects.toBe(failure);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(received).toEqual([]);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a path on the API URL and drops its query', async () => {
|
||||
await expect(
|
||||
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
|
||||
@@ -401,6 +623,29 @@ describe('host action connection', () => {
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('explains an unavailable preview server instead of a raw HTTP 404', async () => {
|
||||
apiStatus = 404;
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'The simulator preview server is unavailable. The session may have ended or expired.'
|
||||
);
|
||||
expect(requests).toEqual(['/api']);
|
||||
expect(wss.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('cleans up a WebSocket upgrade refused after the API accepted its token', async () => {
|
||||
refuseUpgrade = true;
|
||||
const upgraded = once(server, 'upgrade');
|
||||
const rejected = expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'refused the connection (HTTP 401)'
|
||||
);
|
||||
const [, socket] = await upgraded;
|
||||
const closed = once(socket, 'close');
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(wss.clients.size).toBe(0);
|
||||
expect(requests).toEqual(['/api']);
|
||||
});
|
||||
|
||||
it('does not send the session token to an API redirect target', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
@@ -423,11 +668,30 @@ describe('host action connection', () => {
|
||||
closeBeforeReady = true;
|
||||
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
/closed the connection during setup.*token.*eas simulator:get/
|
||||
'closed the connection during setup'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
it('terminates its owned socket when setup never becomes ready', async () => {
|
||||
sendReady = false;
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
const connected = once(wss, 'connection');
|
||||
const rejected = expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
'did not accept the connection within 30 seconds'
|
||||
);
|
||||
const [client] = await connected;
|
||||
const closed = once(client, 'close');
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(wss.clients.size).toBe(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'closes a refused HTTP %i upgrade without waiting for its body',
|
||||
async status => {
|
||||
let rejectedSocket: Duplex | undefined;
|
||||
@@ -446,7 +710,9 @@ describe('host action connection', () => {
|
||||
|
||||
try {
|
||||
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
|
||||
`refused the connection (HTTP ${status})`
|
||||
status === 404
|
||||
? 'The simulator preview server is unavailable. The session may have ended or expired.'
|
||||
: `refused the connection (HTTP ${status})`
|
||||
);
|
||||
await Promise.race([
|
||||
closed,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Parser } from '@oclif/core';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Simulator from '../../commands/simulator';
|
||||
import { prepareControlConfigAsync } from '../controlConfig';
|
||||
import { resolveStartConfigAsync } from '../startConfig';
|
||||
|
||||
describe('simulator start configuration', () => {
|
||||
let folder: string;
|
||||
let file: string;
|
||||
beforeEach(async () => {
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-start-config-'));
|
||||
file = path.join(folder, 'simulator.json');
|
||||
});
|
||||
afterEach(async () => fs.remove(folder));
|
||||
|
||||
async function resolve(
|
||||
start: object,
|
||||
argv: string[] = [],
|
||||
actions?: object[]
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof resolveStartConfigAsync>> & {
|
||||
parsed: Awaited<ReturnType<typeof Parser.parse>>;
|
||||
}
|
||||
> {
|
||||
await fs.writeJson(file, { version: 1, start, ...(actions ? { actions } : {}) });
|
||||
const resolved = await resolveStartConfigAsync(['--import', file, ...argv], Simulator.flags);
|
||||
return { ...resolved, parsed: await Parser.parse(resolved.argv, { flags: Simulator.flags }) };
|
||||
}
|
||||
|
||||
it('uses saved values rather than command defaults and preserves explicit CLI overrides', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{
|
||||
platform: 'ios',
|
||||
type: 'web-preview-only',
|
||||
force: false,
|
||||
device: 'Saved phone',
|
||||
tag: ['saved'],
|
||||
'public-link': true,
|
||||
},
|
||||
['--device', 'CLI phone', '--tag', 'one', '--tag', 'two', '--json']
|
||||
);
|
||||
expect(parsed.flags).toMatchObject({
|
||||
platform: 'ios',
|
||||
type: 'web-preview-only',
|
||||
force: false,
|
||||
device: 'CLI phone',
|
||||
tag: ['one', 'two'],
|
||||
'public-link': true,
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets explicit CLI defaults override different saved values', async () => {
|
||||
const { parsed } = await resolve({ type: 'web-preview-only', force: false }, [
|
||||
'--type',
|
||||
'agent-device',
|
||||
'--force',
|
||||
]);
|
||||
expect(parsed.flags.type).toBe('agent-device');
|
||||
expect(parsed.flags.force).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the CLI disable saved public links and network capture with its saved fields', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{ 'public-link': true, 'network-capture': true, 'network-capture-field': ['header'] },
|
||||
['--no-public-link', '--no-network-capture']
|
||||
);
|
||||
expect(parsed.flags['public-link']).toBe(false);
|
||||
expect(parsed.flags['network-capture']).toBe(false);
|
||||
expect(parsed.flags['network-capture-field']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates dependencies after merging and replaces array flags instead of appending', async () => {
|
||||
const { parsed } = await resolve(
|
||||
{ 'network-capture': true, 'network-capture-field': ['header'] },
|
||||
['--network-capture-field', 'request-body']
|
||||
);
|
||||
expect(parsed.flags['network-capture-field']).toEqual(['request-body']);
|
||||
});
|
||||
|
||||
it('lets an explicit app source replace a saved exclusive source and its Expo Go SDK', async () => {
|
||||
const expoGo = await resolve({ 'build-id': 'saved-build' }, ['--expo-go']);
|
||||
expect(expoGo.parsed.flags['expo-go']).toBe(true);
|
||||
expect(expoGo.parsed.flags['build-id']).toBeUndefined();
|
||||
const build = await resolve({ 'expo-go': true, 'sdk-version': '55.0.0' }, [
|
||||
'--build-id',
|
||||
'cli-build',
|
||||
]);
|
||||
expect(build.parsed.flags['build-id']).toBe('cli-build');
|
||||
expect(build.parsed.flags['expo-go']).toBeUndefined();
|
||||
expect(build.parsed.flags['sdk-version']).toBeUndefined();
|
||||
await expect(resolve({}, ['--build-id', 'cli-build', '--expo-go'])).rejects.toThrow(
|
||||
'cannot also be provided'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty import path instead of silently creating a default session', async () => {
|
||||
await expect(resolveStartConfigAsync(['--import='], Simulator.flags)).rejects.toThrow(
|
||||
'non-empty configuration file path'
|
||||
);
|
||||
});
|
||||
|
||||
it('honors short flags and negated CLI booleans', async () => {
|
||||
const { parsed } = await resolve({ platform: 'android', force: true }, [
|
||||
'-p',
|
||||
'ios',
|
||||
'--no-force',
|
||||
]);
|
||||
expect(parsed.flags.platform).toBe('ios');
|
||||
expect(parsed.flags.force).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: { unknown: 'x' }, message: 'Unknown or invocation-only' },
|
||||
{ start: { json: true }, message: 'invocation-only' },
|
||||
{ start: { 'non-interactive': true }, message: 'invocation-only' },
|
||||
{ start: { import: 'nested.json' }, message: 'invocation-only' },
|
||||
{ start: { 'expo-go': 'yes' }, message: 'must be a boolean' },
|
||||
{ start: { device: ['one', 'two'] }, message: 'multiple values' },
|
||||
{ start: { device: { name: 'phone' } }, message: 'string or number' },
|
||||
{ start: { platform: 'windows' }, message: 'ios' },
|
||||
{ start: { 'max-duration-minutes': -1 }, message: 'greater than or equal to 0' },
|
||||
{ start: { 'build-id': 'abc', 'expo-go': true }, message: 'cannot also be provided' },
|
||||
])('rejects invalid start values: $start', async ({ start, message }) => {
|
||||
await expect(resolve(start)).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it('rejects a missing local input before creating a session', async () => {
|
||||
await expect(
|
||||
resolve({ platform: 'ios' }, [], [{ action: 'app.install', file: './missing.ipa' }])
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
});
|
||||
|
||||
it('rejects invalid output parents and device overrides during local preflight', async () => {
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'screenshot.capture', output: './missing/shot.png' }])
|
||||
).rejects.toThrow('Config step 1');
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'appearance.get', params: { udid: 'other-device' } }])
|
||||
).rejects.toThrow('remove params.udid');
|
||||
});
|
||||
|
||||
it.each(['./missing/', './file.txt/'])(
|
||||
'rejects an output directory that does not exist: %s',
|
||||
async output => {
|
||||
await fs.writeFile(path.join(folder, 'file.txt'), 'existing file');
|
||||
await expect(resolve({}, [], [{ action: 'screenshot.capture', output }])).rejects.toThrow(
|
||||
'must be an existing directory'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['./screenshots/', './screen.png'])(
|
||||
'accepts an existing output directory or a new file: %s',
|
||||
async output => {
|
||||
await fs.mkdir(path.join(folder, 'screenshots'));
|
||||
await expect(
|
||||
resolve({}, [], [{ action: 'screenshot.capture', output }])
|
||||
).resolves.toBeDefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('allows the same file in control import and resolves files relative to it', async () => {
|
||||
await fs.writeFile(path.join(folder, 'input.txt'), 'contents');
|
||||
const { config } = await resolve(
|
||||
{ platform: 'ios' },
|
||||
[],
|
||||
[{ action: 'upload', file: './input.txt' }]
|
||||
);
|
||||
const prepared = await prepareControlConfigAsync(
|
||||
{
|
||||
upload: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['udid', 'path'],
|
||||
upload: 'path',
|
||||
},
|
||||
},
|
||||
file,
|
||||
'device'
|
||||
);
|
||||
expect(prepared.actions[0].plan.localFile?.path).toBe(path.join(folder, 'input.txt'));
|
||||
expect(config?.actions?.[0].params).toEqual({});
|
||||
});
|
||||
|
||||
it('reports an unreadable file before trying to create a session', async () => {
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
`Could not read simulator configuration ${file}`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves no-import argv and reports malformed or unknown config fields', async () => {
|
||||
expect(await resolveStartConfigAsync(['--platform', 'ios'], Simulator.flags)).toEqual({
|
||||
argv: ['--platform', 'ios'],
|
||||
});
|
||||
await fs.writeJson(file, { version: 2, start: {} });
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
'Invalid simulator configuration'
|
||||
);
|
||||
await fs.writeJson(file, { version: 1, typo: {} });
|
||||
await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow(
|
||||
'Invalid simulator configuration'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from 'fs-extra';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { readSimulatorSessionConfigAsync } from '../sessionConfig';
|
||||
import { applyStartConfigAsync } from '../startSetup';
|
||||
|
||||
// Real HTTP/WebSocket transport and file IO verify the shared runner wiring, not just mocks.
|
||||
describe(applyStartConfigAsync, () => {
|
||||
let server: http.Server;
|
||||
let sockets: WebSocketServer;
|
||||
let folder: string;
|
||||
let apiUrl: string;
|
||||
let actions: { action: string; params: Record<string, unknown> }[];
|
||||
const catalog = {
|
||||
set: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, value: { type: 'string' } },
|
||||
required: ['udid', 'value'],
|
||||
},
|
||||
screenshot: {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' } },
|
||||
required: ['udid'],
|
||||
output: 'base64',
|
||||
},
|
||||
};
|
||||
beforeEach(async () => {
|
||||
actions = [];
|
||||
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'start-setup-'));
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.url !== '/api' || req.headers.authorization !== 'Bearer token') {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ device: 'owned-device' }));
|
||||
});
|
||||
sockets = new WebSocketServer({
|
||||
server,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: protocols =>
|
||||
protocols.has('serve-sim.token.token') ? 'serve-sim.token.token' : false,
|
||||
});
|
||||
sockets.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', bytes => {
|
||||
const message = JSON.parse(bytes.toString());
|
||||
actions.push(message);
|
||||
const stdout =
|
||||
message.action === 'actions.describe'
|
||||
? JSON.stringify(catalog)
|
||||
: message.action === 'screenshot'
|
||||
? Buffer.from('image bytes').toString('base64')
|
||||
: 'done';
|
||||
socket.send(JSON.stringify({ id: message.id, stdout, stderr: '', exitCode: 0 }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets.clients) {
|
||||
socket.terminate();
|
||||
}
|
||||
sockets.close();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
await fs.remove(folder);
|
||||
});
|
||||
|
||||
it('runs the captured config after the file changes, writes outputs relative to it and closes transport', async () => {
|
||||
const file = path.join(folder, 'session.json');
|
||||
await fs.writeJson(file, {
|
||||
version: 1,
|
||||
start: { platform: 'ios' },
|
||||
actions: [
|
||||
{ action: 'set', params: { value: 'dark' } },
|
||||
{ action: 'screenshot', output: './screen.png' },
|
||||
],
|
||||
});
|
||||
const config = await readSimulatorSessionConfigAsync(file);
|
||||
await fs.writeJson(file, { version: 999 });
|
||||
const closed = new Promise<void>(resolve =>
|
||||
sockets.once('connection', socket => socket.once('close', resolve))
|
||||
);
|
||||
const progress = jest.fn();
|
||||
const results = await applyStartConfigAsync({ apiUrl, token: 'token' }, config, {
|
||||
signal: new AbortController().signal,
|
||||
onProgress: progress,
|
||||
});
|
||||
await closed;
|
||||
expect(results.map(result => result.action)).toEqual(['set', 'screenshot']);
|
||||
expect(await fs.readFile(path.join(folder, 'screen.png'), 'utf8')).toBe('image bytes');
|
||||
expect(actions.every(action => action.params.udid === 'owned-device')).toBe(true);
|
||||
expect(progress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ step: 2, total: 2, status: 'completed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('validates all actions before sending the first one and closes on validation failure', async () => {
|
||||
const file = path.join(folder, 'session.json');
|
||||
await fs.writeJson(file, {
|
||||
version: 1,
|
||||
actions: [{ action: 'set', params: { value: 'dark' } }, { action: 'unknown' }],
|
||||
});
|
||||
const config = await readSimulatorSessionConfigAsync(file);
|
||||
const closed = new Promise<void>(resolve =>
|
||||
sockets.once('connection', socket => socket.once('close', resolve))
|
||||
);
|
||||
await expect(
|
||||
applyStartConfigAsync({ apiUrl, token: 'token' }, config, {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Unknown simulator action');
|
||||
await closed;
|
||||
expect(actions.map(action => action.action)).toEqual(['actions.describe']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from './hostActions';
|
||||
|
||||
export type ControlAction = {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
outputPath?: string;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export type ControlActionResult = { output: string; file: string | null };
|
||||
|
||||
export type PreparedControlAction = Readonly<{
|
||||
action: string;
|
||||
entry: HostActionCatalogEntry;
|
||||
params: Readonly<Record<string, unknown>>;
|
||||
outputPath?: string;
|
||||
localFile?: Readonly<{ param: string; path: string }>;
|
||||
}>;
|
||||
|
||||
export function resolveControlActionPath(value: string, baseDir?: string): string {
|
||||
if (!baseDir) {
|
||||
return value;
|
||||
}
|
||||
const resolved = path.resolve(baseDir, value);
|
||||
return value.endsWith('/') || value.endsWith(path.sep) ? `${resolved}${path.sep}` : resolved;
|
||||
}
|
||||
|
||||
export async function prepareControlActionAsync(
|
||||
catalog: HostActionCatalog,
|
||||
action: ControlAction,
|
||||
{
|
||||
localFileMode = 'auto',
|
||||
baseDir,
|
||||
}: { localFileMode?: 'auto' | 'explicit'; baseDir?: string } = {}
|
||||
): Promise<PreparedControlAction> {
|
||||
const entry = Object.hasOwn(catalog, action.action) ? catalog[action.action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action.action}". Run \`eas simulator:control --list\` to see the actions.`
|
||||
);
|
||||
}
|
||||
const params = { ...action.params };
|
||||
const outputPath = action.outputPath
|
||||
? resolveControlActionPath(action.outputPath, baseDir)
|
||||
: action.outputPath;
|
||||
await assertHostActionOutputAsync(action.action, entry, outputPath);
|
||||
|
||||
let localFile: PreparedControlAction['localFile'];
|
||||
const param = entry.upload;
|
||||
const applies = Object.entries(entry.uploadWhen ?? {}).every(
|
||||
([name, expected]) => params[name] === expected
|
||||
);
|
||||
if (action.file !== undefined) {
|
||||
if (!param || !applies) {
|
||||
throw new Error(`${action.action} does not accept a local file with these parameters.`);
|
||||
}
|
||||
if (Object.hasOwn(params, param)) {
|
||||
throw new Error(`${param} and file cannot both be supplied for ${action.action}.`);
|
||||
}
|
||||
localFile = { param, path: resolveControlActionPath(action.file, baseDir) };
|
||||
await assertLocalFileAsync(localFile.path);
|
||||
} else if (localFileMode === 'auto' && param && applies && typeof params[param] === 'string') {
|
||||
const value = params[param];
|
||||
if (value.startsWith('host:')) {
|
||||
if (entry.uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
} else {
|
||||
const localPath = resolveControlActionPath(value, baseDir);
|
||||
const stat = await fs.stat(localPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
await assertLocalFileAsync(localPath, stat);
|
||||
localFile = { param, path: localPath };
|
||||
}
|
||||
}
|
||||
return {
|
||||
action: action.action,
|
||||
entry,
|
||||
params,
|
||||
outputPath,
|
||||
...(localFile ? { localFile } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertLocalFileAsync(localPath: string, stats?: fs.Stats): Promise<void> {
|
||||
const stat = stats ?? (await fs.stat(localPath).catch(() => null));
|
||||
if (!stat) {
|
||||
throw new Error(`${localPath} does not exist on this computer.`);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${localPath} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${localPath} is not a regular file.`);
|
||||
}
|
||||
if (stat.size === 0) {
|
||||
throw new Error(`${localPath} is empty. Pass a file with content.`);
|
||||
}
|
||||
await fs.access(localPath, fs.constants.R_OK);
|
||||
}
|
||||
|
||||
export async function executeControlActionAsync(
|
||||
connection: HostActionConnection,
|
||||
plan: PreparedControlAction,
|
||||
{ validateParams }: { validateParams?: (params: Record<string, unknown>) => void } = {}
|
||||
): Promise<ControlActionResult> {
|
||||
connection.signal.throwIfAborted();
|
||||
const params: Record<string, unknown> = { ...plan.params };
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (plan.localFile) {
|
||||
const upload = await uploadFileAsync(connection, plan.localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[plan.localFile.param] = plan.entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
}
|
||||
connection.signal.throwIfAborted();
|
||||
validateParams?.(params);
|
||||
const result = await connection.requestAsync(plan.action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${plan.action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
const file = await saveHostActionOutputAsync(
|
||||
connection,
|
||||
plan.entry,
|
||||
result.stdout,
|
||||
plan.outputPath
|
||||
);
|
||||
return { output: file ? '' : result.stdout.trim(), file };
|
||||
} finally {
|
||||
if (uploadId && (!plan.entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseControlActionOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Ajv2020, { AnySchema } from 'ajv/dist/2020';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
ControlActionResult,
|
||||
PreparedControlAction,
|
||||
assertLocalFileAsync,
|
||||
executeControlActionAsync,
|
||||
parseControlActionOutput,
|
||||
prepareControlActionAsync,
|
||||
resolveControlActionPath,
|
||||
} from './controlActions';
|
||||
import { HostActionCatalog, HostActionConnection, createHostActionUploadId } from './hostActions';
|
||||
import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig';
|
||||
|
||||
type ConfigAction = {
|
||||
readonly plan: PreparedControlAction;
|
||||
readonly validateParams: (params: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type PreparedControlConfig = {
|
||||
readonly path: string;
|
||||
readonly udid: string;
|
||||
readonly actions: readonly ConfigAction[];
|
||||
};
|
||||
|
||||
export type ControlConfigResult = ControlActionResult & { action: string };
|
||||
|
||||
export type ControlConfigProgress = {
|
||||
step: number;
|
||||
total: number;
|
||||
action: string;
|
||||
} & ({ status: 'running' } | { status: 'completed'; result: ControlConfigResult });
|
||||
|
||||
export class ControlConfigExecutionError extends Error {
|
||||
constructor(
|
||||
readonly step: number,
|
||||
readonly action: string,
|
||||
readonly completed: readonly ControlConfigResult[],
|
||||
cause: unknown
|
||||
) {
|
||||
super(
|
||||
`Config step ${step} (${action}) failed after ${completed.length} completed actions: ${errorMessage(cause)}`,
|
||||
{ cause }
|
||||
);
|
||||
this.name = 'ControlConfigExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareControlConfigAsync(
|
||||
catalog: HostActionCatalog,
|
||||
filePath: string,
|
||||
udid: string,
|
||||
input?: SimulatorSessionConfig
|
||||
): Promise<PreparedControlConfig> {
|
||||
const config = input ?? (await readSimulatorSessionConfigAsync(filePath));
|
||||
const absolutePath = config.path;
|
||||
if (!config.actions?.length) {
|
||||
throw new Error(
|
||||
`Invalid simulator configuration ${absolutePath}: actions must contain at least one action.`
|
||||
);
|
||||
}
|
||||
|
||||
const ajv = addFormats(new Ajv2020({ strict: false, allErrors: true }));
|
||||
const actions: ConfigAction[] = [];
|
||||
for (const [index, action] of config.actions.entries()) {
|
||||
try {
|
||||
if (Object.hasOwn(action.params, 'udid')) {
|
||||
throw new Error('The simulator is selected by the session; remove params.udid.');
|
||||
}
|
||||
const plan = await prepareControlActionAsync(
|
||||
catalog,
|
||||
{ ...action, outputPath: action.output },
|
||||
{ localFileMode: 'explicit', baseDir: path.dirname(absolutePath) }
|
||||
);
|
||||
if (plan.entry.output) {
|
||||
if (!plan.outputPath) {
|
||||
throw new Error('Set output to a file or directory for this action.');
|
||||
}
|
||||
await assertWritableOutputAsync(plan.outputPath);
|
||||
}
|
||||
assertUnambiguousFileSource(ajv, plan, udid);
|
||||
const validate = ajv.compile(plan.entry as AnySchema);
|
||||
const validateParams = (params: Record<string, unknown>): void => {
|
||||
if (!validate({ ...params, udid })) {
|
||||
throw new Error(ajv.errorsText(validate.errors, { dataVar: 'params' }));
|
||||
}
|
||||
};
|
||||
const params = { ...plan.params };
|
||||
if (plan.localFile) {
|
||||
const uploadId = createHostActionUploadId(plan.localFile.path);
|
||||
params[plan.localFile.param] =
|
||||
plan.entry.uploadAs === 'id' ? uploadId : `/tmp/serve-sim-uploads/${uploadId}`;
|
||||
}
|
||||
validateParams(params);
|
||||
actions.push({ plan, validateParams });
|
||||
} catch (error) {
|
||||
throw new Error(`Config step ${index + 1} (${action.action}): ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { path: absolutePath, udid, actions };
|
||||
}
|
||||
|
||||
/** Check local inputs before requesting a paid session; runtime validation still uses the live catalog. */
|
||||
export async function preflightControlConfigFilesAsync(
|
||||
config: SimulatorSessionConfig
|
||||
): Promise<void> {
|
||||
for (const [index, action] of (config.actions ?? []).entries()) {
|
||||
try {
|
||||
if (Object.hasOwn(action.params, 'udid')) {
|
||||
throw new Error('The simulator is selected by the session; remove params.udid.');
|
||||
}
|
||||
if (action.file) {
|
||||
await assertLocalFileAsync(path.resolve(path.dirname(config.path), action.file));
|
||||
}
|
||||
if (action.output) {
|
||||
await assertWritableOutputAsync(
|
||||
resolveControlActionPath(action.output, path.dirname(config.path))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Config step ${index + 1} (${action.action}): ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertWritableOutputAsync(outputPath: string): Promise<void> {
|
||||
const stat = await fs.stat(outputPath).catch(() => null);
|
||||
if ((outputPath.endsWith('/') || outputPath.endsWith(path.sep)) && !stat?.isDirectory()) {
|
||||
throw new Error(`${outputPath} must be an existing directory.`);
|
||||
}
|
||||
if (stat && !stat.isFile() && !stat.isDirectory()) {
|
||||
throw new Error(`${outputPath} must be a regular file or directory.`);
|
||||
}
|
||||
await fs.access(
|
||||
stat ? outputPath : path.dirname(outputPath),
|
||||
fs.constants.W_OK | (stat?.isFile() ? 0 : fs.constants.X_OK)
|
||||
);
|
||||
}
|
||||
|
||||
function declaresParameter(schema: unknown, name: string): boolean {
|
||||
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
||||
return false;
|
||||
}
|
||||
const { properties, allOf } = schema as { properties?: object; allOf?: unknown[] };
|
||||
return (
|
||||
(properties !== undefined && Object.hasOwn(properties, name)) ||
|
||||
(Array.isArray(allOf) && allOf.some(child => declaresParameter(child, name)))
|
||||
);
|
||||
}
|
||||
|
||||
function assertUnambiguousFileSource(
|
||||
ajv: Ajv2020,
|
||||
plan: PreparedControlAction,
|
||||
udid: string
|
||||
): void {
|
||||
if (!plan.localFile) {
|
||||
return;
|
||||
}
|
||||
const { anyOf, oneOf, ...common } = plan.entry;
|
||||
for (const branch of anyOf ?? oneOf ?? []) {
|
||||
if (declaresParameter(branch, plan.localFile.param)) {
|
||||
continue;
|
||||
}
|
||||
const validate = ajv.compile({
|
||||
...common,
|
||||
allOf: [...(common.allOf ?? []), branch],
|
||||
} as AnySchema);
|
||||
if (validate({ ...plan.params, udid })) {
|
||||
throw new Error('file conflicts with another source in params. Supply only one file source.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeControlConfigAsync(
|
||||
connection: HostActionConnection,
|
||||
config: PreparedControlConfig,
|
||||
{ onProgress }: { onProgress?: (progress: ControlConfigProgress) => void } = {}
|
||||
): Promise<ControlConfigResult[]> {
|
||||
if (config.udid !== connection.udid) {
|
||||
throw new Error('The simulator changed after this configuration was checked. Import it again.');
|
||||
}
|
||||
const completed: ControlConfigResult[] = [];
|
||||
for (const [index, { plan, validateParams }] of config.actions.entries()) {
|
||||
const progress = { step: index + 1, total: config.actions.length, action: plan.action };
|
||||
onProgress?.({ ...progress, status: 'running' });
|
||||
let result: ControlActionResult;
|
||||
try {
|
||||
result = await executeControlActionAsync(connection, plan, { validateParams });
|
||||
} catch (error) {
|
||||
throw new ControlConfigExecutionError(index + 1, plan.action, completed, error);
|
||||
}
|
||||
const entry = { action: plan.action, ...result };
|
||||
completed.push(entry);
|
||||
onProgress?.({ ...progress, status: 'completed', result: entry });
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function serializeControlConfigResults(
|
||||
results: readonly ControlConfigResult[]
|
||||
): { action: string; output: unknown; file: string | null }[] {
|
||||
return results.map(result => ({ ...result, output: parseControlActionOutput(result.output) }));
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ControlAction } from './controlActions';
|
||||
import { ControlPromptCancelledError, promptControlValueAsync } from './controlPrompts';
|
||||
import { HostActionCatalog, HostActionCatalogEntry, NUMBER_PATTERN } from './hostActions';
|
||||
import Log from '../log';
|
||||
|
||||
type Schema = Record<string, unknown>;
|
||||
type Fields = { properties: Record<string, unknown>; required: Set<string> };
|
||||
|
||||
function isObject(value: unknown): value is Schema {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getObjectFields(schema: Schema): Fields | null {
|
||||
if (schema.anyOf || schema.oneOf || (schema.type && schema.type !== 'object')) {
|
||||
return null;
|
||||
}
|
||||
const result: Fields = {
|
||||
properties: isObject(schema.properties) ? { ...schema.properties } : {},
|
||||
required: new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []),
|
||||
};
|
||||
for (const child of Array.isArray(schema.allOf) ? schema.allOf : []) {
|
||||
const nested = isObject(child) ? getObjectFields(child) : null;
|
||||
if (!nested) {
|
||||
return null;
|
||||
}
|
||||
Object.assign(result.properties, nested.properties);
|
||||
nested.required.forEach(name => result.required.add(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function selectFieldsAsync(
|
||||
entry: HostActionCatalogEntry,
|
||||
signal: AbortSignal
|
||||
): Promise<{ fields: Fields; params: Schema } | null> {
|
||||
const alternatives = entry.oneOf ?? entry.anyOf;
|
||||
if (!alternatives) {
|
||||
const result = getObjectFields(entry);
|
||||
return result && { fields: result, params: {} };
|
||||
}
|
||||
const branches = alternatives.map(branch => (isObject(branch) ? getObjectFields(branch) : null));
|
||||
if (branches.some(branch => !branch)) {
|
||||
return null;
|
||||
}
|
||||
const available = branches as Fields[];
|
||||
let selected: number | undefined;
|
||||
const params: Schema = {};
|
||||
for (const name of Object.keys(available[0]?.properties ?? {})) {
|
||||
if (name === 'udid') {
|
||||
continue;
|
||||
}
|
||||
const values = available.map(branch => branch.properties[name]);
|
||||
if (!values.every(schema => isObject(schema) && Object.hasOwn(schema, 'const'))) {
|
||||
continue;
|
||||
}
|
||||
const constants = values.map(schema => (schema as Schema).const);
|
||||
if (new Set(constants.map(value => JSON.stringify(value))).size !== constants.length) {
|
||||
continue;
|
||||
}
|
||||
const value = await promptControlValueAsync(
|
||||
{
|
||||
type: 'select',
|
||||
message: name,
|
||||
choices: constants.map(value => ({ title: String(value), value })),
|
||||
},
|
||||
signal
|
||||
);
|
||||
selected = constants.findIndex(constant => constant === value);
|
||||
params[name] = value;
|
||||
break;
|
||||
}
|
||||
if (selected === undefined) {
|
||||
// Some upload actions expose a path to users but also accept an internal upload ID.
|
||||
const exposed = entry.args ?? (entry.upload ? [entry.upload] : []);
|
||||
const candidates = available
|
||||
.map((branch, index) => ({ branch, index }))
|
||||
.filter(
|
||||
({ branch }) =>
|
||||
exposed.length > 0 && exposed.every(name => Object.hasOwn(branch.properties, name))
|
||||
);
|
||||
if (candidates.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
selected = candidates[0].index;
|
||||
}
|
||||
const common = getObjectFields({ ...entry, anyOf: undefined, oneOf: undefined });
|
||||
if (!common || !available[selected]) {
|
||||
return null;
|
||||
}
|
||||
const chosen = available[selected];
|
||||
return {
|
||||
fields: {
|
||||
properties: { ...common.properties, ...chosen.properties },
|
||||
required: new Set([...common.required, ...chosen.required]),
|
||||
},
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function getSchemaTypes(schema: Schema): string[] {
|
||||
return [
|
||||
schema.type ?? [],
|
||||
...(Array.isArray(schema.anyOf) ? schema.anyOf : []),
|
||||
...(Array.isArray(schema.oneOf) ? schema.oneOf : []),
|
||||
].flatMap(value =>
|
||||
typeof value === 'string'
|
||||
? [value]
|
||||
: Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: isObject(value)
|
||||
? getSchemaTypes(value)
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
function parseValue(value: string, schema: Schema): unknown {
|
||||
const accepted = getSchemaTypes(schema);
|
||||
if (usesJson(accepted)) {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
const type = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed;
|
||||
if (
|
||||
accepted.includes(type) ||
|
||||
(type === 'number' && accepted.includes('integer') && Number.isInteger(parsed))
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
throw new Error('Enter a JSON value matching this parameter.');
|
||||
}
|
||||
if ((accepted.includes('number') || accepted.includes('integer')) && NUMBER_PATTERN.test(value)) {
|
||||
const number = Number(value);
|
||||
if (
|
||||
Number.isFinite(number) &&
|
||||
(!accepted.includes('integer') || accepted.includes('number') || Number.isInteger(number))
|
||||
) {
|
||||
return number;
|
||||
}
|
||||
}
|
||||
if (accepted.includes('string') || accepted.length === 0) {
|
||||
return value;
|
||||
}
|
||||
throw new Error('Enter a valid number.');
|
||||
}
|
||||
|
||||
function usesJson(accepted: string[]): boolean {
|
||||
return ['object', 'array', 'boolean', 'null'].some(type => accepted.includes(type));
|
||||
}
|
||||
|
||||
async function promptFieldAsync(name: string, raw: unknown, signal: AbortSignal): Promise<unknown> {
|
||||
const schema = isObject(raw) ? raw : {};
|
||||
if (Object.hasOwn(schema, 'const')) {
|
||||
return schema.const;
|
||||
}
|
||||
if (typeof schema.description === 'string') {
|
||||
Log.gray(schema.description);
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return await promptControlValueAsync(
|
||||
{
|
||||
type: 'select',
|
||||
message: name,
|
||||
choices: schema.enum.map(value => ({ title: String(value), value })),
|
||||
},
|
||||
signal
|
||||
);
|
||||
}
|
||||
const accepted = getSchemaTypes(schema);
|
||||
if (accepted.length === 1 && accepted[0] === 'boolean') {
|
||||
return await promptControlValueAsync(
|
||||
{ type: 'toggle', message: name, active: 'true', inactive: 'false', initial: false },
|
||||
signal
|
||||
);
|
||||
}
|
||||
const json = usesJson(accepted);
|
||||
const value = await promptControlValueAsync(
|
||||
{
|
||||
type: 'text',
|
||||
message: `${name}${json ? ' (JSON)' : ''}`,
|
||||
validate(value: string) {
|
||||
try {
|
||||
parseValue(value, schema);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
signal
|
||||
);
|
||||
return parseValue(String(value), schema);
|
||||
}
|
||||
|
||||
export async function promptControlActionAsync(
|
||||
action: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
signal: AbortSignal
|
||||
): Promise<ControlAction> {
|
||||
const selected = await selectFieldsAsync(entry, signal);
|
||||
let params: Record<string, unknown>;
|
||||
if (!selected) {
|
||||
const value = await promptControlValueAsync(
|
||||
{
|
||||
type: 'text',
|
||||
message: 'Parameters (JSON object, without udid)',
|
||||
validate(value: string) {
|
||||
try {
|
||||
return isObject(JSON.parse(value)) || 'Enter a JSON object.';
|
||||
} catch {
|
||||
return 'Enter a JSON object.';
|
||||
}
|
||||
},
|
||||
},
|
||||
signal
|
||||
);
|
||||
params = JSON.parse(String(value));
|
||||
} else {
|
||||
params = selected.params;
|
||||
const names = [...new Set([...(entry.args ?? []), ...Object.keys(selected.fields.properties)])];
|
||||
for (const name of names) {
|
||||
if (
|
||||
name === 'udid' ||
|
||||
Object.hasOwn(params, name) ||
|
||||
!Object.hasOwn(selected.fields.properties, name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const schema = selected.fields.properties[name];
|
||||
if (!selected.fields.required.has(name)) {
|
||||
const hint =
|
||||
isObject(schema) && Object.hasOwn(schema, 'default')
|
||||
? ` (default: ${JSON.stringify(schema.default)})`
|
||||
: '';
|
||||
const include = await promptControlValueAsync(
|
||||
{ type: 'confirm', message: `Set optional ${name}${hint}?`, initial: false },
|
||||
signal
|
||||
);
|
||||
if (!include) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
params[name] = await promptFieldAsync(name, schema, signal);
|
||||
}
|
||||
}
|
||||
const result: ControlAction = { action, params };
|
||||
if (entry.output) {
|
||||
const outputPath = await promptControlValueAsync(
|
||||
{
|
||||
type: 'text',
|
||||
message: 'Save file to',
|
||||
validate: (value: string) => value.trim().length > 0 || 'Enter a local output path.',
|
||||
},
|
||||
signal
|
||||
);
|
||||
result.outputPath = String(outputPath);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runControlMenuAsync({
|
||||
catalog,
|
||||
signal,
|
||||
runActionAsync,
|
||||
}: {
|
||||
catalog: HostActionCatalog;
|
||||
signal: AbortSignal;
|
||||
runActionAsync: (action: ControlAction) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const choices = [
|
||||
{ title: 'Exit', description: 'Close simulator controls', value: { kind: 'exit' as const } },
|
||||
...Object.keys(catalog)
|
||||
.sort()
|
||||
.map(name => ({
|
||||
title: name.split('.').map(actionLabel).join(' › '),
|
||||
description: name,
|
||||
value: { kind: 'action' as const, name },
|
||||
})),
|
||||
];
|
||||
signal.throwIfAborted();
|
||||
Log.gray(
|
||||
`${Object.keys(catalog).length} actions · Type to search · ↑/↓ to navigate · Enter to select · Ctrl-C to exit`
|
||||
);
|
||||
while (true) {
|
||||
signal.throwIfAborted();
|
||||
const choice = (await promptControlValueAsync(
|
||||
{
|
||||
type: 'autocomplete',
|
||||
message: 'Choose a simulator action',
|
||||
choices,
|
||||
limit: 8,
|
||||
suggest: (input: string) => {
|
||||
const words = input.toLowerCase().trim().split(/\s+/);
|
||||
return Promise.resolve(
|
||||
choices.filter(choice => {
|
||||
const description =
|
||||
choice.value.kind === 'action'
|
||||
? (catalog[choice.value.name].description ?? '')
|
||||
: '';
|
||||
const text = `${choice.title} ${choice.description} ${description}`.toLowerCase();
|
||||
return words.every(word => text.includes(word));
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
signal
|
||||
)) as { kind: 'exit' } | { kind: 'action'; name: string };
|
||||
signal.throwIfAborted();
|
||||
if (!choice) {
|
||||
Log.error('Choose an action or Exit.');
|
||||
continue;
|
||||
}
|
||||
if (choice.kind === 'exit') {
|
||||
Log.gray('Simulator controls closed.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold(choice.name));
|
||||
if (catalog[choice.name].description) {
|
||||
Log.gray(catalog[choice.name].description);
|
||||
}
|
||||
await runActionAsync(
|
||||
await promptControlActionAsync(choice.name, catalog[choice.name], signal)
|
||||
);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (error instanceof ControlPromptCancelledError) {
|
||||
throw error;
|
||||
}
|
||||
Log.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(value: string): string {
|
||||
const words = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]/g, ' ')
|
||||
.toLowerCase();
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Question, promptAsync } from '../prompts';
|
||||
|
||||
export class ControlPromptCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Simulator control interrupted.');
|
||||
this.name = 'ControlPromptCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
type ActivePrompt = { abort(): void; closed?: boolean; done?: boolean };
|
||||
|
||||
/** Unlike the default prompt cancellation, this unwinds through the connection's cleanup. */
|
||||
export async function promptControlValueAsync(
|
||||
question: Omit<Question<'value'>, 'name'>,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
signal.throwIfAborted();
|
||||
let active: ActivePrompt | undefined;
|
||||
let abortQueued = false;
|
||||
const abort = (): void => {
|
||||
if (abortQueued) {
|
||||
return;
|
||||
}
|
||||
abortQueued = true;
|
||||
// The first render happens inside the prompt constructor, before its abort listener exists.
|
||||
queueMicrotask(() => {
|
||||
abortQueued = false;
|
||||
if (active && !active.closed && !active.done) {
|
||||
active.abort();
|
||||
}
|
||||
});
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
const result = await promptAsync(
|
||||
{
|
||||
...question,
|
||||
name: 'value',
|
||||
onRender(this: ActivePrompt, kleur) {
|
||||
active = this;
|
||||
question.onRender?.call(this, kleur);
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
onCancel() {
|
||||
signal.throwIfAborted();
|
||||
throw new ControlPromptCancelledError();
|
||||
},
|
||||
}
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
return result.value;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionStatus, JobRunStatus } from '../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../log';
|
||||
|
||||
export class ControlSessionEndedError extends Error {
|
||||
constructor(
|
||||
sessionId: string,
|
||||
readonly failed = false
|
||||
) {
|
||||
super(`Simulator session ${sessionId} ${failed ? 'errored' : 'has ended'}.`);
|
||||
this.name = 'ControlSessionEndedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Watches status without extending or stopping the remote session. */
|
||||
export function watchControlSession(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
sessionId: string,
|
||||
{
|
||||
signal,
|
||||
pollIntervalMs = 5000,
|
||||
requestTimeoutMs = 30_000,
|
||||
}: { signal: AbortSignal; pollIntervalMs?: number; requestTimeoutMs?: number }
|
||||
): { signal: AbortSignal; dispose(): void } {
|
||||
const controller = new AbortController();
|
||||
let pollTimer: NodeJS.Timeout | undefined;
|
||||
let cancelRequest: (() => void) | undefined;
|
||||
|
||||
function stop(reason?: unknown): void {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pollTimer);
|
||||
cancelRequest?.();
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
controller.abort(reason);
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
stop(signal.reason);
|
||||
}
|
||||
|
||||
function schedulePoll(): void {
|
||||
if (!controller.signal.aborted) {
|
||||
pollTimer = setTimeout(poll, pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function retry(error: unknown): void {
|
||||
Log.debug(`Could not check simulator session ${sessionId}:`, error);
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function poll(): void {
|
||||
pollTimer = undefined;
|
||||
let subscription: { unsubscribe(): void } | undefined;
|
||||
let requestTimer: NodeJS.Timeout | undefined;
|
||||
let finished = false;
|
||||
|
||||
function finishRequest(): boolean {
|
||||
if (finished) {
|
||||
return false;
|
||||
}
|
||||
finished = true;
|
||||
clearTimeout(requestTimer);
|
||||
// urql tears down the operation and aborts the HTTP fetch on unsubscribe.
|
||||
subscription?.unsubscribe();
|
||||
cancelRequest = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelRequest = () => {
|
||||
finishRequest();
|
||||
};
|
||||
requestTimer = setTimeout(() => {
|
||||
if (finishRequest()) {
|
||||
retry(new Error('Session status request timed out.'));
|
||||
}
|
||||
}, requestTimeoutMs);
|
||||
|
||||
try {
|
||||
subscription = DeviceRunSessionQuery.byId(graphqlClient, sessionId, {
|
||||
requestPolicy: 'network-only',
|
||||
noRetry: true,
|
||||
}).subscribe(result => {
|
||||
if (result.stale || result.hasNext || !finishRequest()) {
|
||||
return;
|
||||
}
|
||||
const session = result.data?.deviceRunSessions?.byId;
|
||||
if (result.error || !session) {
|
||||
retry(result.error ?? new Error('Session status response was empty.'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
[DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored].includes(
|
||||
session.status
|
||||
) ||
|
||||
(session.turtleJobRun &&
|
||||
[JobRunStatus.Canceled, JobRunStatus.Finished, JobRunStatus.Errored].includes(
|
||||
session.turtleJobRun.status
|
||||
))
|
||||
) {
|
||||
const failed =
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
session.turtleJobRun?.status === JobRunStatus.Errored;
|
||||
stop(new ControlSessionEndedError(sessionId, failed));
|
||||
} else {
|
||||
schedulePoll();
|
||||
}
|
||||
});
|
||||
// A synchronous source may finish before subscribe returns its handle.
|
||||
if (finished) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
} catch (error) {
|
||||
if (finishRequest()) {
|
||||
retry(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
stop(signal.reason);
|
||||
} else {
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
schedulePoll();
|
||||
}
|
||||
return { signal: controller.signal, dispose: stop };
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export type HostActionResult = {
|
||||
};
|
||||
|
||||
export type HostActionConnection = {
|
||||
readonly signal: AbortSignal;
|
||||
readonly udid: string;
|
||||
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
|
||||
close(): void;
|
||||
};
|
||||
@@ -42,6 +44,8 @@ const Catalog = z.record(z.string(), CatalogEntry);
|
||||
export type HostActionCatalog = z.infer<typeof Catalog>;
|
||||
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
|
||||
|
||||
export type HostActionDevice = { udid: string; name?: string; runtime?: string };
|
||||
|
||||
const PreviewConfig = z.object({ device: z.string().min(1) });
|
||||
|
||||
const ReplyId = z.object({ id: z.number() });
|
||||
@@ -59,9 +63,11 @@ const SocketMessage = z.union([
|
||||
|
||||
const HOST_ACTION_TIMEOUT_MS = 130_000;
|
||||
const PREVIEW_API_TIMEOUT_MS = 30_000;
|
||||
const PREVIEW_UNAVAILABLE_MESSAGE =
|
||||
'The simulator preview server is unavailable. The session may have ended or expired. Check it with `eas simulator:get`, or start a new session with `eas simulator:start`.';
|
||||
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
|
||||
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
|
||||
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
||||
export const NUMBER_PATTERN = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
||||
|
||||
function previewEndpoint(apiUrl: string, endpoint: string): URL {
|
||||
const base = new URL(apiUrl);
|
||||
@@ -82,16 +88,25 @@ function previewEndpoint(apiUrl: string, endpoint: string): URL {
|
||||
return new URL(endpoint, base);
|
||||
}
|
||||
|
||||
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
|
||||
async function fetchSimulatorUdidAsync(
|
||||
{ apiUrl, token }: PreviewApiAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
let text: string;
|
||||
try {
|
||||
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error instanceof RequestError && error.response.status === 404) {
|
||||
throw new Error(PREVIEW_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
|
||||
throw new Error(
|
||||
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
|
||||
@@ -117,6 +132,31 @@ async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Pro
|
||||
return config.data.device;
|
||||
}
|
||||
|
||||
export async function fetchHostActionDeviceAsync(
|
||||
{ apiUrl, token }: PreviewApiAccess,
|
||||
udid: string
|
||||
): Promise<HostActionDevice> {
|
||||
const url = previewEndpoint(apiUrl, 'grid/api/catalog').toString();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
|
||||
});
|
||||
const catalog = z
|
||||
.object({
|
||||
devices: z.array(
|
||||
z.object({ device: z.string(), name: z.string(), runtime: z.string().optional() })
|
||||
),
|
||||
})
|
||||
.parse(await response.json());
|
||||
const device = catalog.devices.find(device => device.device === udid);
|
||||
return device ? { udid, name: device.name, runtime: device.runtime } : { udid };
|
||||
} catch {
|
||||
return { udid };
|
||||
}
|
||||
}
|
||||
|
||||
function refusedError(error: string): Error {
|
||||
return error.startsWith('unknown action')
|
||||
? new Error(
|
||||
@@ -127,9 +167,11 @@ function refusedError(error: string): Error {
|
||||
|
||||
export async function connectHostActionsAsync(
|
||||
access: PreviewApiAccess,
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
{ timeoutMs = HOST_ACTION_TIMEOUT_MS, signal }: { timeoutMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<HostActionConnection> {
|
||||
const udid = await fetchSimulatorUdidAsync(access);
|
||||
signal?.throwIfAborted();
|
||||
const udid = await fetchSimulatorUdidAsync(access, signal);
|
||||
signal?.throwIfAborted();
|
||||
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
|
||||
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(
|
||||
@@ -144,6 +186,7 @@ export async function connectHostActionsAsync(
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
const pending = new Map<number, Pending>();
|
||||
const controller = new AbortController();
|
||||
let nextId = 1;
|
||||
let lost: Error | null = null;
|
||||
let ready = false;
|
||||
@@ -158,30 +201,39 @@ export async function connectHostActionsAsync(
|
||||
}
|
||||
|
||||
function loseConnection(error: Error): void {
|
||||
lost ??= error;
|
||||
if (lost) {
|
||||
return;
|
||||
}
|
||||
lost = error;
|
||||
controller.abort(error);
|
||||
for (const id of [...pending.keys()]) {
|
||||
settle(id)?.reject(error);
|
||||
}
|
||||
ws.terminate();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
|
||||
)
|
||||
);
|
||||
ws.terminate();
|
||||
}, PREVIEW_API_TIMEOUT_MS);
|
||||
const fail = (error: Error): void => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
loseConnection(error);
|
||||
if (!ready) {
|
||||
ws.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = (): void => {
|
||||
fail(
|
||||
signal?.reason instanceof Error ? signal.reason : new Error('Simulator setup canceled.')
|
||||
);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
ws.once('close', () => signal?.removeEventListener('abort', onAbort));
|
||||
|
||||
ws.on('message', data => {
|
||||
let message: unknown;
|
||||
try {
|
||||
@@ -220,7 +272,9 @@ export async function connectHostActionsAsync(
|
||||
res.destroy();
|
||||
fail(
|
||||
new Error(
|
||||
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
res.statusCode === 404
|
||||
? PREVIEW_UNAVAILABLE_MESSAGE
|
||||
: `The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -240,28 +294,42 @@ export async function connectHostActionsAsync(
|
||||
)
|
||||
);
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
udid,
|
||||
async requestAsync(action, params = {}) {
|
||||
if (lost) {
|
||||
throw lost;
|
||||
}
|
||||
const id = nextId++;
|
||||
const message = JSON.stringify({ id, action, params: { ...params, udid } });
|
||||
return await new Promise<HostActionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(id)?.reject(
|
||||
loseConnection(
|
||||
new Error(
|
||||
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
|
||||
try {
|
||||
ws.send(message, error => {
|
||||
if (error) {
|
||||
loseConnection(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
loseConnection(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
},
|
||||
close() {
|
||||
ws.close();
|
||||
loseConnection(new Error('The simulator action connection was closed.'));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -350,7 +418,10 @@ function coerce(value: string, schemas: unknown[]): unknown {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (NUMBER.test(value) && schemas.some(schema => acceptsType(schema, ['number', 'integer']))) {
|
||||
if (
|
||||
NUMBER_PATTERN.test(value) &&
|
||||
schemas.some(schema => acceptsType(schema, ['number', 'integer']))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
@@ -447,12 +518,16 @@ export async function assertHostActionOutputAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostActionUploadId(localPath: string): string {
|
||||
return `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
}
|
||||
|
||||
export async function uploadFileAsync(
|
||||
connection: HostActionConnection,
|
||||
localPath: string,
|
||||
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
|
||||
): Promise<{ uploadId: string; path: string }> {
|
||||
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
|
||||
const uploadId = createHostActionUploadId(localPath);
|
||||
let remotePath = '';
|
||||
let first = true;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const Action = z.strictObject({
|
||||
action: z.string().min(1),
|
||||
params: z.record(z.string(), z.json()).default({}),
|
||||
file: z.string().min(1).optional(),
|
||||
output: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const Config = z.strictObject({
|
||||
version: z.literal(1),
|
||||
start: z.record(z.string(), z.json()).optional(),
|
||||
actions: z.array(Action).min(1).optional(),
|
||||
});
|
||||
|
||||
export type SimulatorSessionConfig = z.infer<typeof Config> & { path: string };
|
||||
|
||||
export async function readSimulatorSessionConfigAsync(
|
||||
filePath: string
|
||||
): Promise<SimulatorSessionConfig> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await fs.readJson(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not read simulator configuration ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
const parsed = Config.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map(issue => `${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Invalid simulator configuration ${absolutePath}: ${detail}`);
|
||||
}
|
||||
return { ...parsed.data, path: absolutePath };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Interfaces, Parser } from '@oclif/core';
|
||||
|
||||
import { preflightControlConfigFilesAsync } from './controlConfig';
|
||||
import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig';
|
||||
|
||||
/** Reparse the effective values using the command's own validation, options and defaults. */
|
||||
export async function resolveStartConfigAsync(
|
||||
argv: string[],
|
||||
definitions: Interfaces.FlagInput
|
||||
): Promise<{ argv: string[]; config?: SimulatorSessionConfig }> {
|
||||
if (!argv.some(arg => arg === '--import' || arg.startsWith('--import='))) {
|
||||
return { argv };
|
||||
}
|
||||
// Relationships are checked after file values and explicit CLI overrides are combined.
|
||||
const relaxed = Object.fromEntries(
|
||||
Object.entries(definitions).map(([name, flag]) => [
|
||||
name,
|
||||
{
|
||||
...flag,
|
||||
dependsOn: undefined,
|
||||
exclusive: undefined,
|
||||
exactlyOne: undefined,
|
||||
atLeastOne: undefined,
|
||||
relationships: undefined,
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
const parsed = await Parser.parse(argv, { flags: relaxed });
|
||||
if (parsed.flags.import === undefined) {
|
||||
return { argv };
|
||||
}
|
||||
if (!parsed.flags.import.trim()) {
|
||||
throw new Error('--import requires a non-empty configuration file path.');
|
||||
}
|
||||
const config = await readSimulatorSessionConfigAsync(parsed.flags.import);
|
||||
const values: Record<string, unknown> = { ...config.start };
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!Object.hasOwn(definitions, key) || ['import', 'json', 'non-interactive'].includes(key)) {
|
||||
throw new Error(`Unknown or invocation-only start setting "${key}" in ${config.path}.`);
|
||||
}
|
||||
}
|
||||
const explicit = Object.entries(parsed.flags).filter(
|
||||
([name, value]) => value !== undefined && !parsed.metadata.flags[name]?.setFromDefault
|
||||
);
|
||||
const explicitNames = new Set(explicit.map(([name]) => name));
|
||||
const removed = new Set<string>();
|
||||
for (const [name, value] of explicit) {
|
||||
for (const excluded of definitions[name].exclusive ?? []) {
|
||||
if (!explicitNames.has(excluded)) {
|
||||
delete values[excluded];
|
||||
removed.add(excluded);
|
||||
}
|
||||
}
|
||||
values[name] = value;
|
||||
}
|
||||
// SDK selection belongs to the saved Expo Go app source, not a replacement build.
|
||||
if (removed.has('expo-go') && !explicitNames.has('sdk-version')) {
|
||||
delete values['sdk-version'];
|
||||
}
|
||||
if (
|
||||
explicitNames.has('network-capture') &&
|
||||
values['network-capture'] === false &&
|
||||
!explicitNames.has('network-capture-field')
|
||||
) {
|
||||
delete values['network-capture-field'];
|
||||
}
|
||||
const effectiveArgv: string[] = [];
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const flag = definitions[name];
|
||||
if (flag.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Start setting "${name}" must be a boolean.`);
|
||||
}
|
||||
if (value) {
|
||||
effectiveArgv.push(`--${name}`);
|
||||
} else if (flag.allowNo) {
|
||||
effectiveArgv.push(`--no-${name}`);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(value) ? value : [value];
|
||||
if (Array.isArray(value) && !flag.multiple) {
|
||||
throw new Error(`Start setting "${name}" does not accept multiple values.`);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (typeof item !== 'string' && typeof item !== 'number') {
|
||||
throw new Error(`Start setting "${name}" must contain a string or number.`);
|
||||
}
|
||||
effectiveArgv.push(`--${name}=${item}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await preflightControlConfigFilesAsync(config);
|
||||
return { argv: effectiveArgv, config };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
ControlConfigProgress,
|
||||
ControlConfigResult,
|
||||
executeControlConfigAsync,
|
||||
prepareControlConfigAsync,
|
||||
} from './controlConfig';
|
||||
import { connectHostActionsAsync, fetchHostActionCatalogAsync } from './hostActions';
|
||||
import { SimulatorSessionConfig } from './sessionConfig';
|
||||
import { PreviewApiAccess } from './utils';
|
||||
|
||||
export async function applyStartConfigAsync(
|
||||
access: PreviewApiAccess,
|
||||
config: SimulatorSessionConfig,
|
||||
{
|
||||
signal,
|
||||
onProgress,
|
||||
}: { signal: AbortSignal; onProgress?: (progress: ControlConfigProgress) => void }
|
||||
): Promise<ControlConfigResult[]> {
|
||||
const connection = await connectHostActionsAsync(access, { signal });
|
||||
try {
|
||||
const catalog = await fetchHostActionCatalogAsync(connection);
|
||||
const prepared = await prepareControlConfigAsync(catalog, config.path, connection.udid, config);
|
||||
signal.throwIfAborted();
|
||||
return await executeControlConfigAsync(connection, prepared, { onProgress });
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user