Compare commits

...
7 changed files with 287 additions and 34 deletions

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.
+6 -6
View File
@@ -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
+4 -3
View File
@@ -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;
+12 -6
View File
@@ -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])) {