mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e695f17f9e | ||
|
|
6508b0ebe0 | ||
|
|
3a58912e8c | ||
|
|
480fc4f754 | ||
|
|
54121dd080 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Validate device names before starting a simulator session and show the available names when a device is not listed. iOS Simulator UDIDs remain supported.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:devices` to list the devices Expo suggests for a simulator session.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionProfilesQuery } from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorDevices from '../devices';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('chalk', () => ({
|
||||
__esModule: true,
|
||||
default: { bold: (text: string) => text, dim: (text: string) => text },
|
||||
}));
|
||||
|
||||
const mockProfilesAsync = jest.mocked(DeviceRunSessionQuery.profilesAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
const mockLog = jest.mocked(Log.log);
|
||||
|
||||
const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] = {
|
||||
__typename: 'DeviceRunSessionProfiles',
|
||||
ios: {
|
||||
__typename: 'IosDeviceRunSessionProfiles',
|
||||
devices: [
|
||||
{
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17',
|
||||
displayName: 'iPhone 17',
|
||||
},
|
||||
{
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17 Pro',
|
||||
displayName: 'iPhone 17 Pro',
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
__typename: 'AndroidDeviceRunSessionProfiles',
|
||||
devices: [
|
||||
{
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'medium_phone',
|
||||
displayName: 'Medium Phone',
|
||||
},
|
||||
{
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'pixel_9',
|
||||
displayName: 'Pixel 9',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const iosDevices = [
|
||||
{ identifier: 'iPhone 17', displayName: 'iPhone 17' },
|
||||
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro' },
|
||||
];
|
||||
const androidDevices = [
|
||||
{ identifier: 'medium_phone', displayName: 'Medium Phone' },
|
||||
{ identifier: 'pixel_9', displayName: 'Pixel 9' },
|
||||
];
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorDevices, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockProfilesAsync.mockResolvedValue(profiles);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorDevices {
|
||||
const command = new SimulatorDevices(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('does not need a project', () => {
|
||||
expect(Object.keys(SimulatorDevices.contextDefinition)).toEqual(['loggedIn']);
|
||||
});
|
||||
|
||||
it('logs the devices of both platforms', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'iOS iPhone 17 "iPhone 17"',
|
||||
'iOS iPhone 17 Pro "iPhone 17 Pro"',
|
||||
'Android Medium Phone medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'Start a session with: eas simulator:start --platform ios --device "iPhone 17"'
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the devices of one platform with --platform', async () => {
|
||||
await createCommand(['--platform', 'android']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'Android Medium Phone medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'Start a session with: eas simulator:start --platform android --device medium_phone'
|
||||
);
|
||||
});
|
||||
|
||||
it('says so when the service lists no devices', async () => {
|
||||
mockProfilesAsync.mockResolvedValue({
|
||||
...profiles,
|
||||
ios: { ...profiles.ios, devices: [] },
|
||||
android: { ...profiles.android, devices: [] },
|
||||
});
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockLog).toHaveBeenCalledWith('No simulator devices found.');
|
||||
});
|
||||
|
||||
it('emits JSON for both platforms', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
ios: iosDevices,
|
||||
android: androidDevices,
|
||||
});
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON for one platform with --platform', async () => {
|
||||
await createCommand(['--json', '-p', 'ios']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ ios: iosDevices });
|
||||
});
|
||||
|
||||
it('propagates a query failure', async () => {
|
||||
mockProfilesAsync.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
|
||||
);
|
||||
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
|
||||
const mockProfilesAsync = jest.mocked(DeviceRunSessionQuery.profilesAsync);
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
|
||||
const mockFetch = jest.mocked(fetch);
|
||||
@@ -205,6 +206,20 @@ describe(Simulator, () => {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
mockProfilesAsync.mockReset().mockResolvedValue({
|
||||
ios: {
|
||||
devices: [
|
||||
{ identifier: 'iPhone 17', displayName: 'iPhone 17' },
|
||||
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro' },
|
||||
],
|
||||
},
|
||||
android: {
|
||||
devices: [
|
||||
{ identifier: 'medium_phone', displayName: 'Medium Phone' },
|
||||
{ identifier: 'pixel_9', displayName: 'Pixel 9' },
|
||||
],
|
||||
},
|
||||
});
|
||||
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
|
||||
id: 'session-123',
|
||||
@@ -287,12 +302,21 @@ describe(Simulator, () => {
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unlisted imported device before creating a session', async () => {
|
||||
config({ platform: 'ios', device: 'iPhnoe 17' });
|
||||
const { command } = createCommand(['--import', configPath, '--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown iOS device "iPhnoe 17".');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(applyStartConfigAsync).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',
|
||||
device: 'iPhone 17',
|
||||
tag: ['saved'],
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--name', 'CLI name', '--json']);
|
||||
@@ -302,7 +326,7 @@ describe(Simulator, () => {
|
||||
expect.objectContaining({
|
||||
name: 'CLI name',
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
ios: { deviceIdentifier: 'Saved phone' },
|
||||
ios: { deviceIdentifier: 'iPhone 17' },
|
||||
tags: ['saved'],
|
||||
})
|
||||
);
|
||||
@@ -328,7 +352,7 @@ describe(Simulator, () => {
|
||||
platform,
|
||||
type: 'web-preview-only',
|
||||
name: 'Startup regression',
|
||||
device: platform === 'ios' ? 'iPhone 17 Pro' : 'pixel_7',
|
||||
device: platform === 'ios' ? 'iPhone 17 Pro' : 'pixel_9',
|
||||
'out-config-type': output,
|
||||
...(egress ? { egress: 'local' } : {}),
|
||||
};
|
||||
@@ -1952,19 +1976,109 @@ describe(Simulator, () => {
|
||||
expect(input).not.toHaveProperty('agentSessionId');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
platform: 'ios',
|
||||
device: 'pixel_9',
|
||||
message:
|
||||
'Unknown iOS device "pixel_9".\n' +
|
||||
'Available --device values:\n "iPhone 17"\n "iPhone 17 Pro"\n' +
|
||||
'Run eas simulator:devices --platform ios to list devices.',
|
||||
},
|
||||
{
|
||||
platform: 'android',
|
||||
device: 'iPhone 17',
|
||||
message:
|
||||
'Unknown Android device "iPhone 17".\n' +
|
||||
'Available --device values:\n "medium_phone" (Medium Phone)\n "pixel_9" (Pixel 9)\n' +
|
||||
'Run eas simulator:devices --platform android to list devices.',
|
||||
},
|
||||
])(
|
||||
'rejects an unlisted $platform device and lists that platform',
|
||||
async ({ platform, device, message }) => {
|
||||
const { command } = createCommand(['--platform', platform, '--device', device, '--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(message);
|
||||
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('validates against the platform selected interactively', async () => {
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Android });
|
||||
const { command } = createCommand(['--device', 'iPhone 17']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown Android device "iPhone 17".');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a session if device profiles cannot be fetched', async () => {
|
||||
mockProfilesAsync.mockRejectedValueOnce(new Error('network down'));
|
||||
const { command } = createCommand(['--platform', 'ios', '--device', 'iPhone 17', '--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('network down');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles an empty device list before creating a session', async () => {
|
||||
mockProfilesAsync.mockResolvedValueOnce({
|
||||
ios: { devices: [] },
|
||||
android: { devices: [] },
|
||||
});
|
||||
const { command } = createCommand(['--platform', 'ios', '--device', 'iPhone 17', '--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('No devices are currently listed.');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'123e4567-e89b-42d3-a456-426614174000',
|
||||
'12345678-1234-1234-1234-123456789abc',
|
||||
'12345678-1234-1234-1234-123456789ABC',
|
||||
])('forwards iOS UDID %s without fetching device profiles', async udid => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--device', udid, '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockProfilesAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ ios: { deviceIdentifier: udid } })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an arbitrary hash as an unlisted iOS device name', async () => {
|
||||
const hash = '12345678123412341234123456789abc';
|
||||
const { command } = createCommand(['--platform', 'ios', '--device', hash, '--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(`Unknown iOS device "${hash}".`);
|
||||
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch device profiles when no device is requested', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockProfilesAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
|
||||
});
|
||||
|
||||
it('forwards --device in the iOS create options', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
'iPhone 16 Pro',
|
||||
'iPhone 17 Pro',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 17 Pro' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2060,13 +2174,13 @@ describe(Simulator, () => {
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
' iPhone 16 Pro ',
|
||||
' iPhone 17 Pro ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 17 Pro' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2075,6 +2189,7 @@ describe(Simulator, () => {
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
|
||||
expect(mockProfilesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits resourceClass when --resource-class is not set', async () => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { appPlatformDisplayNames } from '../../platform';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
} from '../../simulator/utils';
|
||||
|
||||
const PLATFORM_ORDER: readonly DeviceRunSessionPlatformFlagValue[] = ['ios', 'android'];
|
||||
|
||||
function formatDeviceFlagValue(identifier: string): string {
|
||||
return /^[\w-]+$/.test(identifier) ? identifier : JSON.stringify(identifier);
|
||||
}
|
||||
|
||||
export default class SimulatorDevices extends EasCommand {
|
||||
static override aliases = getSimulatorCommandAliases('devices');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('devices');
|
||||
static override description = 'list available devices for a remote simulator session';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Show the devices of one platform',
|
||||
options: DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorDevices);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorDevices, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const fetchSpinner = jsonFlag ? null : ora('Fetching simulator devices').start();
|
||||
let profiles;
|
||||
try {
|
||||
profiles = await DeviceRunSessionQuery.profilesAsync(graphqlClient);
|
||||
fetchSpinner?.stop();
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to fetch simulator devices');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const platforms = PLATFORM_ORDER.filter(
|
||||
platform => !flags.platform || flags.platform === platform
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(
|
||||
Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
profiles[platform].devices.map(({ identifier, displayName }) => ({
|
||||
identifier,
|
||||
displayName,
|
||||
})),
|
||||
])
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = platforms.flatMap(platform =>
|
||||
profiles[platform].devices.map(({ identifier, displayName }) => ({
|
||||
platform,
|
||||
displayName,
|
||||
flagValue: formatDeviceFlagValue(identifier),
|
||||
}))
|
||||
);
|
||||
if (devices.length === 0) {
|
||||
Log.log('No simulator devices found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const header = ['Platform', 'Device', '--device value'];
|
||||
const rows = devices.map(({ platform, displayName, flagValue }) => [
|
||||
appPlatformDisplayNames[DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE[platform]],
|
||||
displayName,
|
||||
flagValue,
|
||||
]);
|
||||
const widths = header.map((title, column) =>
|
||||
Math.max(title.length, ...rows.map(row => row[column].length))
|
||||
);
|
||||
const formatRow = (row: string[]): string =>
|
||||
row
|
||||
.map((cell, column) => cell.padEnd(widths[column]))
|
||||
.join(' ')
|
||||
.trimEnd();
|
||||
Log.log([chalk.dim(formatRow(header)), ...rows.map(formatRow)].join('\n'));
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Start a session with: eas simulator:start --platform ${devices[0].platform} --device ${devices[0].flagValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,11 +63,14 @@ import {
|
||||
simulatorRequestProperties,
|
||||
} from '../../simulator/requestAnalytics';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
@@ -88,13 +91,9 @@ const OUT_CONFIG_TYPE_VALUES = {
|
||||
Env: 'env',
|
||||
Dotenv: 'dotenv',
|
||||
} as const;
|
||||
const PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
type PlatformFlagValue = (typeof PLATFORM_FLAG_VALUES)[number];
|
||||
const EGRESS_FLAG_VALUES = ['local'] as const;
|
||||
const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
const IOS_SIMULATOR_UDID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export default class Simulator extends EasCommand {
|
||||
static override aliases = ['simulator:start', ...getSimulatorCommandAliases('start')];
|
||||
@@ -131,7 +130,7 @@ With local egress, actions wait for the connection and require interactive mode.
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
options: PLATFORM_FLAG_VALUES,
|
||||
options: DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES,
|
||||
})(),
|
||||
name: Flags.string({
|
||||
description:
|
||||
@@ -144,7 +143,7 @@ With local egress, actions wait for the connection and require interactive mode.
|
||||
}),
|
||||
device: Flags.string({
|
||||
description:
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
|
||||
'Virtual device to start for the session. On iOS, a listed Simulator device name or UDID (e.g. "iPhone 17 Pro"). On Android, a listed AVD hardware profile id (e.g. "pixel_9"). Defaults to a device chosen by the runner. Run eas simulator:devices to list device names.',
|
||||
}),
|
||||
'system-image': Flags.string({
|
||||
description:
|
||||
@@ -399,6 +398,10 @@ With local egress, actions wait for the connection and require interactive mode.
|
||||
);
|
||||
}
|
||||
|
||||
if (deviceIdentifier) {
|
||||
await validateDeviceIdentifierAsync(graphqlClient, platform, deviceIdentifier);
|
||||
}
|
||||
|
||||
const expoGoSdkVersion = flags['expo-go']
|
||||
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
|
||||
: undefined;
|
||||
@@ -896,12 +899,44 @@ async function formatJobRunErrorsAsync(
|
||||
return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join('');
|
||||
}
|
||||
|
||||
async function validateDeviceIdentifierAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
platform: AppPlatform,
|
||||
deviceIdentifier: string
|
||||
): Promise<void> {
|
||||
if (platform === AppPlatform.Ios && IOS_SIMULATOR_UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const platformName = platform === AppPlatform.Ios ? 'iOS' : 'Android';
|
||||
const platformFlag = platformName.toLowerCase() as DeviceRunSessionPlatformFlagValue;
|
||||
const profiles = await DeviceRunSessionQuery.profilesAsync(graphqlClient);
|
||||
const devices = profiles[platformFlag].devices;
|
||||
if (devices.some(({ identifier }) => identifier === deviceIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceList = devices
|
||||
.map(
|
||||
({ identifier, displayName }) =>
|
||||
` ${JSON.stringify(identifier)}${identifier === displayName ? '' : ` (${displayName})`}`
|
||||
)
|
||||
.join('\n');
|
||||
throw new EasCommandError(
|
||||
`Unknown ${platformName} device ${JSON.stringify(deviceIdentifier)}.\n` +
|
||||
(deviceList
|
||||
? `Available --device values:\n${deviceList}\n`
|
||||
: 'No devices are currently listed.\n') +
|
||||
`Run eas simulator:devices --platform ${platformFlag} to list devices.`
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePlatformAsync(
|
||||
platform: PlatformFlagValue | undefined,
|
||||
platform: DeviceRunSessionPlatformFlagValue | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<AppPlatform> {
|
||||
if (platform) {
|
||||
return APP_PLATFORM_BY_FLAG_VALUE[platform];
|
||||
return DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE[platform];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
|
||||
+5
@@ -16432,6 +16432,11 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionProfilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string }> } } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionJobRunErrorsByIdQuery,
|
||||
DeviceRunSessionJobRunErrorsByIdQueryVariables,
|
||||
DeviceRunSessionProfilesQuery,
|
||||
DeviceRunSessionProfilesQueryVariables,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
DeviceRunSessionsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -200,6 +202,39 @@ export const DeviceRunSessionQuery = {
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
async profilesAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionProfilesQuery, DeviceRunSessionProfilesQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionProfilesQuery {
|
||||
deviceRunSessions {
|
||||
profiles {
|
||||
ios {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
}
|
||||
}
|
||||
android {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.profiles;
|
||||
},
|
||||
async listByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SimulatorAvailability from '../../commands/simulator/availability';
|
||||
import SimulatorControl from '../../commands/simulator/control';
|
||||
import SimulatorDevices from '../../commands/simulator/devices';
|
||||
import SimulatorEgress from '../../commands/simulator/egress';
|
||||
import SimulatorEvents from '../../commands/simulator/events';
|
||||
import SimulatorExec from '../../commands/simulator/exec';
|
||||
@@ -14,6 +15,7 @@ describe('simulator command aliases', () => {
|
||||
it.each([
|
||||
['availability', SimulatorAvailability],
|
||||
['control', SimulatorControl],
|
||||
['devices', SimulatorDevices],
|
||||
['egress', SimulatorEgress],
|
||||
['events', SimulatorEvents],
|
||||
['exec', SimulatorExec],
|
||||
|
||||
@@ -56,6 +56,17 @@ export const DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS = [
|
||||
...new Set(Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES)),
|
||||
];
|
||||
|
||||
export const DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
export type DeviceRunSessionPlatformFlagValue =
|
||||
(typeof DEVICE_RUN_SESSION_PLATFORM_FLAG_VALUES)[number];
|
||||
export const DEVICE_RUN_SESSION_PLATFORM_BY_FLAG_VALUE: Record<
|
||||
DeviceRunSessionPlatformFlagValue,
|
||||
AppPlatform
|
||||
> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
// Every enum value behind a flag value, so filtering by web-preview-only also
|
||||
// matches sessions created with the legacy ServeSim type.
|
||||
export function deviceRunSessionTypesForFlagValue(value: string): DeviceRunSessionType[] {
|
||||
|
||||
Reference in new issue
Block a user