mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0db455bdf | ||
|
|
eda1e20b33 | ||
|
|
136386db6b | ||
|
|
4db642380e | ||
|
|
a30678faa6 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Fix simulator control help routing, preserve literal arguments after `--`, and require `host:` for simulator-host file paths. Package local `.app` bundles when the session supports app archives.
|
||||
@@ -2788,8 +2788,8 @@ USAGE
|
||||
$ eas sim:control [ACTION...] [-h] [--id <value>] [--list] [--output <value>] [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[ACTION...] 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...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are
|
||||
positional literals, including text that starts with -- or name=.
|
||||
|
||||
FLAGS
|
||||
-h, --help Show command help, or usage for the selected action from the session.
|
||||
@@ -2821,7 +2821,7 @@ EXAMPLES
|
||||
|
||||
$ eas simulator:control permissions.set grant camera com.example.app
|
||||
|
||||
$ eas simulator:control app.install ./MyApp.ipa
|
||||
$ eas simulator:control app.install ./MyApp.app
|
||||
|
||||
$ eas simulator:control screenshot.capture --output ./screenshot.png
|
||||
|
||||
@@ -3251,8 +3251,8 @@ USAGE
|
||||
$ eas simulator:control [ACTION...] [-h] [--id <value>] [--list] [--output <value>] [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[ACTION...] 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...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are
|
||||
positional literals, including text that starts with -- or name=.
|
||||
|
||||
FLAGS
|
||||
-h, --help Show command help, or usage for the selected action from the session.
|
||||
@@ -3284,7 +3284,7 @@ EXAMPLES
|
||||
|
||||
$ eas simulator:control permissions.set grant camera com.example.app
|
||||
|
||||
$ eas simulator:control app.install ./MyApp.ipa
|
||||
$ eas simulator:control app.install ./MyApp.app
|
||||
|
||||
$ eas simulator:control screenshot.capture --output ./screenshot.png
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
@@ -9,7 +8,7 @@ describe(EasHelp, () => {
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
@@ -21,12 +20,14 @@ describe(EasHelp, () => {
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -395,17 +396,185 @@ describe(SimulatorControl, () => {
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a path that is not a local file through unchanged', async () => {
|
||||
actionResult = ok('');
|
||||
it('refuses a missing absolute local file without running an action', async () => {
|
||||
const missing = path.join(tmpDir, 'missing.png');
|
||||
await expect(
|
||||
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes an explicit host path through without uploading a matching local file', async () => {
|
||||
const localFile = path.join(tmpDir, 'existing.png');
|
||||
await fs.writeFile(localFile, 'local bytes');
|
||||
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
|
||||
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
|
||||
});
|
||||
|
||||
it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
|
||||
await expect(
|
||||
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('absolute');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['text=hello', '--', '--help'])(
|
||||
'preserves the literal value after the separator: %s',
|
||||
async value => {
|
||||
served = {
|
||||
...catalog,
|
||||
'clipboard.write': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, text: { type: 'string' } },
|
||||
required: ['udid', 'text'],
|
||||
},
|
||||
};
|
||||
await createCommand(['clipboard.write', '--id', 'session-123', '--', value]).runAsync();
|
||||
expect(actionRequests()).toEqual([['clipboard.write', { text: value }]]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not treat a flag value of -- as the literal separator', async () => {
|
||||
served = {
|
||||
...catalog,
|
||||
'clipboard.write': {
|
||||
type: 'object',
|
||||
properties: { udid: { type: 'string' }, text: { type: 'string' } },
|
||||
required: ['udid', 'text'],
|
||||
},
|
||||
};
|
||||
await createCommand(['clipboard.write', 'text=hello', '--id', '--']).runAsync();
|
||||
expect(actionRequests()).toEqual([['clipboard.write', { text: 'hello' }]]);
|
||||
});
|
||||
|
||||
it('keeps named arguments before the separator and positional values after it', async () => {
|
||||
await createCommand([
|
||||
'app.install',
|
||||
'/Users/runner/MyApp.ipa',
|
||||
'permissions.set',
|
||||
'action=grant',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--',
|
||||
'service=camera',
|
||||
'bundleId=literal',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([
|
||||
[
|
||||
'permissions.set',
|
||||
{ action: 'grant', service: 'service=camera', bundleId: 'bundleId=literal' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
|
||||
it.each([false, true])(
|
||||
'packages a local app bundle and cleans it after failure=%s',
|
||||
async fails => {
|
||||
const bundle = path.join(tmpDir, 'My App.app');
|
||||
await fs.mkdir(bundle);
|
||||
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
|
||||
served = {
|
||||
...catalog,
|
||||
'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' },
|
||||
};
|
||||
const directories = await fs.readdir(os.tmpdir());
|
||||
actionResult = fails ? { stdout: '', stderr: 'install rejected', exitCode: 1 } : ok('');
|
||||
const command = createCommand([
|
||||
'app.install',
|
||||
`${bundle}/`,
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
if (fails) {
|
||||
await expect(command).rejects.toThrow('install rejected');
|
||||
} else {
|
||||
await command;
|
||||
}
|
||||
const requests = actionRequests();
|
||||
expect(requests.map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'app.install',
|
||||
'upload.remove',
|
||||
]);
|
||||
const data = Buffer.concat(
|
||||
requests
|
||||
.filter(([action]) => action === 'upload.append')
|
||||
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
|
||||
);
|
||||
const file = path.join(tmpDir, 'received.tgz');
|
||||
await fs.writeFile(file, data);
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file,
|
||||
onReadEntry: entry => {
|
||||
entries.push(entry.path);
|
||||
},
|
||||
});
|
||||
expect(entries).toContain('My App.app/Info.plist');
|
||||
const newDirectories = (await fs.readdir(os.tmpdir())).filter(
|
||||
name => name.startsWith('eas-simulator-app-') && !directories.includes(name)
|
||||
);
|
||||
expect(newDirectories).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('packages the contents of an app bundle reached through a local symlink', async () => {
|
||||
const actual = path.join(tmpDir, 'Actual');
|
||||
const link = path.join(tmpDir, 'Linked.app');
|
||||
await fs.mkdir(actual);
|
||||
await fs.writeFile(path.join(actual, 'Info.plist'), 'app plist');
|
||||
await fs.symlink(actual, link);
|
||||
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
|
||||
await createCommand(['app.install', link, '--id', 'session-123']).runAsync();
|
||||
const data = Buffer.concat(
|
||||
actionRequests()
|
||||
.filter(([action]) => action === 'upload.append')
|
||||
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
|
||||
);
|
||||
const file = path.join(tmpDir, 'received.tgz');
|
||||
await fs.writeFile(file, data);
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file,
|
||||
onReadEntry: entry => {
|
||||
entries.push(entry.path);
|
||||
},
|
||||
});
|
||||
expect(entries).toContain('Linked.app/Info.plist');
|
||||
});
|
||||
|
||||
it('cleans a packaged bundle when uploading fails', async () => {
|
||||
const bundle = path.join(tmpDir, 'Upload.app');
|
||||
await fs.mkdir(bundle);
|
||||
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
|
||||
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
|
||||
const before = await fs.readdir(os.tmpdir());
|
||||
requestAsync
|
||||
.mockResolvedValueOnce(ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'upload rejected', exitCode: 1 });
|
||||
await expect(
|
||||
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('upload rejected');
|
||||
expect(actionRequests().map(([action]) => action)).toEqual(['upload.append', 'upload.remove']);
|
||||
expect(
|
||||
(await fs.readdir(os.tmpdir())).filter(
|
||||
name => name.startsWith('eas-simulator-app-') && !before.includes(name)
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses bundle upload to a host that does not advertise app archives', async () => {
|
||||
const bundle = path.join(tmpDir, 'My.app');
|
||||
await fs.mkdir(bundle);
|
||||
await expect(
|
||||
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not support app archives');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('continues rejecting unrelated folders', async () => {
|
||||
await expect(
|
||||
createCommand(['media.add', tmpDir, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('is a folder');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
@@ -45,7 +47,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.ipa',
|
||||
'$ eas simulator:control app.install ./MyApp.app',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png',
|
||||
'$ eas simulator:control screenshot.capture --output ./screenshot.png --id <session-id>',
|
||||
];
|
||||
@@ -53,7 +55,7 @@ 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 to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are positional literals, including text that starts with -- or name=.',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -82,6 +84,21 @@ export default class SimulatorControl extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, argv, flags } = await this.parse(SimulatorControl);
|
||||
let literalFrom = argv.length - 1;
|
||||
for (let index = 0; index < this.argv.length; index++) {
|
||||
const value = this.argv[index];
|
||||
if (value === '--') {
|
||||
literalFrom = argv.length - 1 - (this.argv.length - index - 1);
|
||||
break;
|
||||
}
|
||||
// A standalone -- consumed as a flag value is not an argument separator.
|
||||
const flag = value.startsWith('--')
|
||||
? SimulatorControl.flags[value.slice(2) as keyof typeof SimulatorControl.flags]
|
||||
: undefined;
|
||||
if (flag?.type === 'option') {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -138,6 +155,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
literalFrom,
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
@@ -198,6 +216,10 @@ export default class SimulatorControl extends EasCommand {
|
||||
' Action arguments refer to the simulator host. Use --output for the local filename.'
|
||||
);
|
||||
}
|
||||
Log.log(' Values after -- are positional literals.');
|
||||
if (entry.upload) {
|
||||
Log.log(' File paths are local. Use host:/absolute/path for files on the simulator host.');
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
@@ -208,19 +230,21 @@ export default class SimulatorControl extends EasCommand {
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
literalFrom,
|
||||
outputPath,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
literalFrom: number;
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values);
|
||||
const params = buildHostActionParams(action, entry, values, literalFrom);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(entry, params);
|
||||
const localFile = await findLocalUploadAsync(action, entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
@@ -251,6 +275,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
await localFile?.cleanupAsync?.();
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -271,21 +296,66 @@ export default class SimulatorControl extends EasCommand {
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
|
||||
action: string,
|
||||
{ upload: param, uploadWhen = {}, uploadArchive }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
): Promise<{ param: string; path: string; cleanupAsync?: () => Promise<void> } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch(() => null);
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
if (value.startsWith('host:')) {
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error('A host: path must be absolute, for example host:/tmp/app.tar.gz.');
|
||||
}
|
||||
params[param] = hostPath;
|
||||
return null;
|
||||
}
|
||||
if (!stat && !path.isAbsolute(value)) {
|
||||
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (stat?.isDirectory()) {
|
||||
if (action !== 'app.install' || path.extname(path.resolve(value)) !== '.app') {
|
||||
throw new Error(
|
||||
`${value} is a folder. Pass the path of a file or an app.install .app bundle.`
|
||||
);
|
||||
}
|
||||
if (uploadArchive !== 'tar.gz') {
|
||||
throw new Error(
|
||||
'This simulator host does not support app archives. Start a session with an updated serve-sim package.'
|
||||
);
|
||||
}
|
||||
const bundle = await fs.realpath(value);
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-simulator-app-'));
|
||||
const archive = path.join(directory, 'app.tar.gz');
|
||||
const cleanupAsync = async (): Promise<void> => {
|
||||
await fs.remove(directory);
|
||||
};
|
||||
try {
|
||||
await tar.create(
|
||||
{
|
||||
cwd: bundle,
|
||||
file: archive,
|
||||
prefix: path.basename(path.resolve(value)),
|
||||
gzip: true,
|
||||
portable: true,
|
||||
},
|
||||
['.']
|
||||
);
|
||||
return { param, path: archive, cleanupAsync };
|
||||
} catch (error) {
|
||||
await cleanupAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
|
||||
`${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;
|
||||
|
||||
@@ -6,13 +6,19 @@ export default class EasHelp extends Help {
|
||||
const [command, ...args] = argv;
|
||||
if (command === 'simulator:control' || command === 'sim:control') {
|
||||
const { default: SimulatorControl } = await import('./commands/simulator/control');
|
||||
const parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
} catch {
|
||||
await super.showHelp(argv);
|
||||
return;
|
||||
}
|
||||
if (parsed.args.action && parsed.flags.help) {
|
||||
await SimulatorControl.run(args, this.config);
|
||||
await this.config.runCommand('simulator:control', args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const CatalogEntry = SchemaNode.extend({
|
||||
description: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
upload: z.string().optional(),
|
||||
uploadArchive: z.string().optional(),
|
||||
uploadAs: z.enum(['path', 'id']).optional(),
|
||||
uploadWhen: z.record(z.string(), z.unknown()).optional(),
|
||||
keepUpload: z.boolean().optional(),
|
||||
@@ -392,13 +393,14 @@ export function formatHostActionUsage(name: string, entry: HostActionCatalogEntr
|
||||
export function buildHostActionParams(
|
||||
name: string,
|
||||
entry: HostActionCatalogEntry,
|
||||
values: string[]
|
||||
values: string[],
|
||||
literalFrom = values.length
|
||||
): Record<string, unknown> {
|
||||
const properties = hostActionProperties(entry);
|
||||
const params: Record<string, unknown> = {};
|
||||
const positional: string[] = [];
|
||||
for (const value of values) {
|
||||
const named = NAMED_VALUE.exec(value);
|
||||
for (const [index, value] of values.entries()) {
|
||||
const named = index < literalFrom ? NAMED_VALUE.exec(value) : null;
|
||||
if (!named || !properties.has(named[1])) {
|
||||
positional.push(value);
|
||||
} else if (Object.hasOwn(params, named[1])) {
|
||||
|
||||
Reference in new issue
Block a user