Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/simulator-control-input-corrections.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions packages/eas-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions packages/eas-cli/src/__tests__/help.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Config, Help } from '@oclif/core';

import SimulatorControl from '../commands/simulator/control';
import EasHelp from '../help';

describe(EasHelp, () => {
Expand All @@ -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();
});

Expand All @@ -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'],
Expand Down
179 changes: 174 additions & 5 deletions packages/eas-cli/src/commands/simulator/__tests__/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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' },
],
]);
});

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([]);
});

expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
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 () => {
Expand Down
Loading
Loading