Compare commits

...
7 changed files with 639 additions and 0 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
@@ -0,0 +1,154 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
import {
getServeSimDeviceUdidAsync,
runServeSimHostActionAsync,
} from '../../../simulator/serveSimHost';
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/serveSimHost', () => ({
...jest.requireActual('../../../simulator/serveSimHost'),
getServeSimDeviceUdidAsync: jest.fn(),
runServeSimHostActionAsync: jest.fn(),
}));
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
const SPRINGBOARD_RESTART = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.Argent,
platform: AppPlatform.Ios,
remoteConfig: {
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: 'preview-token',
},
...overrides,
} as DeviceRunSessionById;
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(parseSimctlArgv, () => {
it('reads --id before the simctl arguments', () => {
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
id: 'session-1',
args: ['ui', 'appearance', 'dark'],
});
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
id: 'session-1',
args: SPRINGBOARD_RESTART,
});
});
it('passes simctl flags through untouched', () => {
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
});
});
describe(SimulatorSimctl, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
mockByIdAsync.mockResolvedValue(makeSession());
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
});
function createCommand(argv: string[]): SimulatorSimctl {
const command = new SimulatorSimctl(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir: '/test/project',
});
return command;
}
it('restarts SpringBoard on the session simulator', async () => {
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
'springboard.restart',
{ udid: UDID }
);
});
it('sets the appearance on the session from the env file', async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
await createCommand(['ui', 'appearance', 'dark']).runAsync();
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
expect.anything(),
'appearance.set',
{ value: 'dark', udid: UDID }
);
});
it('rejects an unsupported command before fetching the session', async () => {
await expect(
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('rejects an Android session', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('only supports iOS');
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
});
it('rejects a session that is not running', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('is stopped, not running');
});
});
@@ -0,0 +1,108 @@
import EasCommand from '../../commandUtils/EasCommand';
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../../simulator/serveSimHost';
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
let id: string | undefined;
let index = 0;
while (index < rawArgv.length) {
const arg = rawArgv[index];
if (arg === '--') {
index += 1;
break;
}
if (arg === '--id') {
id = rawArgv[index + 1];
if (!id) {
throw new Error('--id needs a simulator session ID.');
}
index += 2;
} else if (arg.startsWith('--id=')) {
id = arg.slice('--id='.length);
index += 1;
} else {
break;
}
}
return { id, args: rawArgv.slice(index) };
}
export default class SimulatorSimctl extends EasCommand {
static override aliases = ['sim:simctl'];
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
command => `\`${command}\``
).join(', ')}`;
static override strict = false;
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
await this.parse(SimulatorSimctl, []);
const { id: flagId, args } = parseSimctlArgv(rawArgv);
const hostAction = resolveSimctlHostAction(args);
if (!hostAction) {
throw new Error(
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
command => ` ${command}`
).join('\n')}`
);
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
await loadSimulatorEnvAsync(projectDir);
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
if (!sessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
if (session.platform !== AppPlatform.Ios) {
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
}
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
);
}
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
if (!host) {
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
}
const udid = await getServeSimDeviceUdidAsync(host);
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
...hostAction.params,
udid,
});
if (stdout) {
Log.log(stdout.trimEnd());
}
if (stderr) {
Log.warn(stderr.trimEnd());
}
}
}
@@ -0,0 +1,162 @@
import { AddressInfo } from 'net';
import nock from 'nock';
import { WebSocketServer } from 'ws';
import {
UnsupportedServeSimHostActionError,
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../serveSimHost';
import { DeviceRunSessionRemoteConfig } from '../utils';
const TOKEN = 'preview-token';
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
describe(getServeSimHost, () => {
it('reads the web preview host of a controller session', () => {
expect(
getServeSimHost({
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('reads the preview host of a serve-sim session', () => {
expect(
getServeSimHost({
__typename: 'ServeSimRunSessionRemoteConfig',
previewApiUrl: 'https://preview.example.com',
previewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('returns null before the session reports its preview host', () => {
expect(
getServeSimHost({
__typename: 'AgentDeviceRunSessionRemoteConfig',
previewApiUrl: null,
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toBeNull();
});
});
describe(getServeSimDeviceUdidAsync, () => {
afterEach(() => {
nock.cleanAll();
});
it('reads the device from the preview state with the session token', async () => {
nock('https://preview.example.com', {
reqheaders: { authorization: `Bearer ${TOKEN}` },
})
.get('/api')
.reply(200, { device: UDID, port: 3200 });
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).resolves.toBe(UDID);
});
it('fails when no simulator is running', async () => {
nock('https://preview.example.com').get('/api').reply(200, 'null');
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).rejects.toThrow('no running simulator');
});
});
describe(runServeSimHostActionAsync, () => {
let server: WebSocketServer;
let apiUrl: string;
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
let protocols: string[];
let requests: unknown[];
beforeEach(async () => {
protocols = [];
requests = [];
server = new WebSocketServer({
port: 0,
path: '/exec-ws',
handleProtocols: offered => {
protocols = [...offered];
return offered.values().next().value ?? false;
},
});
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', data => {
const request = JSON.parse(data.toString());
requests.push(request);
socket.send(JSON.stringify(reply(request)));
});
});
await new Promise<void>(resolve => {
server.once('listening', resolve);
});
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
it('runs one action over the token-authenticated exec channel', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).resolves.toEqual({ stdout: '', stderr: '' });
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
expect(requests).toEqual([
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
]);
});
it('reports a rejected session token', async () => {
server.removeAllListeners('connection');
server.on('connection', socket => {
socket.close();
});
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('rejected the session token');
});
it('reports an action the session does not have yet', async () => {
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
).rejects.toThrow(UnsupportedServeSimHostActionError);
});
it('reports a failed command with its stderr', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('appearance.set failed: Invalid device');
});
});
@@ -0,0 +1,43 @@
import { resolveSimctlHostAction } from '../simctl';
describe(resolveSimctlHostAction, () => {
it('maps the appearance command', () => {
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
action: 'appearance.set',
params: { value: 'dark' },
});
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
action: 'appearance.set',
params: { value: 'light' },
});
});
it('maps the SpringBoard restart', () => {
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
])
).toEqual({ action: 'springboard.restart', params: {} });
});
it('rejects every other command', () => {
expect(resolveSimctlHostAction([])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.backboardd',
])
).toBeNull();
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
});
});
@@ -0,0 +1,133 @@
import WebSocket from 'ws';
import fetch, { httpsProxyAgent } from '../fetch';
import { DeviceRunSessionRemoteConfig } from './utils';
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
export type ServeSimHost = {
apiUrl: string;
token: string;
};
export type ServeSimHostActionResult = {
stdout: string;
stderr: string;
};
export class UnsupportedServeSimHostActionError extends Error {}
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
switch (remoteConfig.__typename) {
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.previewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
: null;
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
case 'AppiumRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
: null;
}
}
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
headers: { Authorization: `Bearer ${host.token}` },
});
const state = (await response.json()) as { device?: unknown } | null;
if (typeof state?.device !== 'string' || state.device.length === 0) {
throw new Error('The simulator session has no running simulator.');
}
return state.device;
}
export async function runServeSimHostActionAsync(
host: ServeSimHost,
action: string,
params: Record<string, unknown>,
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
): Promise<ServeSimHostActionResult> {
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
const socket = new WebSocket(
url.toString(),
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
{
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
}
);
let settled = false;
let sent = false;
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
socket.terminate();
if (error) {
reject(error);
} else {
resolve(result!);
}
};
const timer = setTimeout(() => {
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
}, timeoutMs);
socket.on('error', error => {
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
});
socket.on('close', () => {
finish(
new Error(
sent
? `The simulator host closed the connection before ${action} finished.`
: 'The simulator host rejected the session token.'
)
);
});
socket.on('message', data => {
let reply: any;
try {
reply = JSON.parse(data.toString());
} catch {
finish(new Error('The simulator host sent an invalid response.'));
return;
}
if (reply?.ready === true && !sent) {
sent = true;
socket.send(JSON.stringify({ id: 1, action, params }));
return;
}
if (reply?.id !== 1 || !sent) {
return;
}
if (typeof reply.error === 'string') {
finish(
reply.error === `unknown action ${action}`
? new UnsupportedServeSimHostActionError(
`This simulator session does not support ${action} yet. Start a new session to use it.`
)
: new Error(`${action} failed: ${reply.error}`)
);
return;
}
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
if (reply.exitCode !== 0) {
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
return;
}
finish(null, { stdout, stderr });
});
});
}
function resolveServeSimUrl(apiUrl: string, route: string): URL {
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
}
+34
View File
@@ -0,0 +1,34 @@
export type SimctlHostAction = {
action: string;
params: Record<string, unknown>;
};
const SPRINGBOARD_RESTART_ARGS = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
export const SUPPORTED_SIMCTL_COMMANDS = [
'ui appearance <light|dark>',
SPRINGBOARD_RESTART_ARGS.join(' '),
];
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
const value = args[2];
if (value === 'light' || value === 'dark') {
return { action: 'appearance.set', params: { value } };
}
return null;
}
if (
args.length === SPRINGBOARD_RESTART_ARGS.length &&
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
) {
return { action: 'springboard.restart', params: {} };
}
return null;
}