Skip to content
Merged
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-keep-alive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---

Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux.
2 changes: 2 additions & 0 deletions packages/eas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"promise-limit": "2.7.0",
"promise-retry": "2.0.1",
"prompts": "2.4.2",
"proper-lockfile": "4.1.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"sandbox-cli-detector": "0.2.0",
Expand Down Expand Up @@ -159,6 +160,7 @@
"@types/pngjs": "6.0.4",
"@types/promise-retry": "1.1.3",
"@types/prompts": "2.4.2",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.5.6",
"@types/tar": "6.1.10",
"@types/tar-stream": "3.1.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ const { createRequire } = require('node:module');
const root = fs.realpathSync(process.argv[2]);
const cli = path.join(root, 'packages/eas-cli');
const cliRequire = createRequire(path.join(cli, 'package.json'));
const { oclif } = cliRequire('./package.json');
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
const { Config } = cliRequire('@oclif/core');
const { Config, run } = cliRequire('@oclif/core');
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
const EasHelp = require(path.join(cli, 'src/help')).default;
const cases = JSON.parse(process.argv[3]);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
const events = [];
let commandArgs;
globalThis.__helpLifecycleEvents = events;

// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
EasCommand.prototype.run = async function () {
commandArgs = this.argv;
events.push({ event: 'run', id: this.id });
};
EasCommand.prototype.finally = async function () {};
Expand All @@ -31,14 +33,26 @@ EasCommand.prototype.finally = async function () {};
version: '1.0.0',
oclif: {
bin: 'eas',
commands: { strategy: 'explicit', target: './commands.cjs' },
additionalHelpFlags: ['-h'],
helpClass: oclif.helpClass,
commands: './commands',
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
},
})
);
for (const command of ['simulator/control', 'simulator/index', 'account/login']) {
const file = path.join(directory, 'commands', `${command}.js`);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
`exports.default = require(${JSON.stringify(path.join(cli, 'src/commands', command))}).default;`
);
}
const helpFile = path.join(directory, `${oclif.helpClass}.js`);
fs.mkdirSync(path.dirname(helpFile), { recursive: true });
fs.writeFileSync(
path.join(directory, 'commands.cjs'),
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
helpFile,
`exports.default = require(${JSON.stringify(path.join(cli, 'src/help'))}).default;`
);
for (const event of ['prerun', 'postrun']) {
fs.writeFileSync(
Expand All @@ -48,13 +62,24 @@ EasCommand.prototype.finally = async function () {};
}
process.env.XDG_DATA_HOME = directory;
process.env.XDG_CACHE_HOME = directory;
const config = await Config.load({ root: directory });
let config = await Config.load({ root: directory });
if (process.argv[4] === 'manifest') {
if (!config.rootPlugin.manifest.commands['simulator:control'].relativePath) {
throw new Error('The manifest must exercise cached command module paths');
}
fs.writeFileSync(
path.join(directory, 'oclif.manifest.json'),
JSON.stringify(config.rootPlugin.manifest)
);
config = await Config.load({ root: directory });
}
const results = [];
for (const args of cases) {
// Published manifests discover metadata without loading the command class.
// Clear eager discovery's assignment to exercise that cold-start boundary.
delete Control.id;
const start = events.length;
commandArgs = undefined;
const write = process.stdout.write;
let stdout = '',
error;
Expand All @@ -65,13 +90,13 @@ EasCommand.prototype.finally = async function () {};
return true;
};
try {
await new EasHelp(config).showHelp(args);
await run(args, config);
} catch (err) {
error = err.message;
} finally {
process.stdout.write = write;
}
results.push({ stdout, events: events.slice(start), error });
results.push({ stdout, events: events.slice(start), error, commandArgs });
}
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
} finally {
Expand Down
105 changes: 78 additions & 27 deletions packages/eas-cli/src/__tests__/help.lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,99 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const root = path.resolve(__dirname, '../../../..');
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
const modes = ['discovery', 'manifest'];
const actionHelp = [
['simulator:control', 'rotate', '--help'],
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
['simulators:control', 'rotate', '--help'],
['emulator:control', 'rotate', '-h'],
['emulators:control', 'rotate', '--help'],
];
const incompleteFlags = [
const configHelp = [
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
['simulator:start', '--help'],
['simulator', '--help'],
['sim:start', '-h'],
['sim', '-h'],
['simulators:start', '--help'],
['emulator:start', '-h'],
['emulators:start', '--help'],
['simulators', '--help'],
['emulator', '-h'],
['emulators', '--help'],
['simulators:control', '--help'],
['emulator:control', '-h'],
['emulators:control', '--help'],
['simulator:control', '--help'],
['sim:control', '-h'],
['--help', 'simulator:start'],
['--help', 'help', 'simulator:start'],
['simulator:start', '--import', 'does-not-exist.json', '--help'],
['simulator:control', '--import', 'does-not-exist.json', '--help'],
];
let results: { stdout: string; events: unknown[]; error?: string }[];
const ordinaryHelp = [['account:login', '--help'], ['--help']];
const literalHelp = ['simulator:control', 'clipboard.write', '--', '--help'];
let results: {
stdout: string;
events: unknown[];
error?: string;
commandArgs?: string[];
}[][];

beforeAll(async () => {
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
const { stdout } = await execFileAsync(
process.execPath,
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
{ timeout: 20_000 }
results = await Promise.all(
modes.map(async mode => {
const { stdout } = await execFileAsync(
process.execPath,
[
fixture,
root,
JSON.stringify([...actionHelp, ...configHelp, ...ordinaryHelp, literalHelp]),
mode,
],
{ timeout: 20_000 }
);
return JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
})
);
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
}, 30_000);

describe('simulator action help through real oclif lookup and hooks', () => {
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
'keeps canonical command identity and executes lifecycle hooks: $argv',
({ index }) => {
expect(results[index].error).toBeUndefined();
expect(results[index].events).toEqual([
describe.each(modes.map((mode, index) => ({ mode, index })))(
'simulator help through real oclif routing ($mode)',
({ index: mode }) => {
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
'keeps command identity and lifecycle hooks for action help: $argv',
({ index }) => {
expect(results[mode][index].error).toBeUndefined();
expect(results[mode][index].events).toEqual([
{ event: 'prerun', id: 'simulator:control' },
{ event: 'run', id: 'simulator:control' },
{ event: 'postrun', id: 'simulator:control' },
]);
}
);

it.each([...configHelp, ...ordinaryHelp].map((argv, index) => ({ argv, index })))(
'prints help without executing a command: $argv',
({ index }) => {
const { stdout, events, error } = results[mode][index + actionHelp.length];
expect(error).toBeUndefined();
expect(stdout).toContain('USAGE');
expect(events).toEqual([]);
}
);

it('keeps --help after -- as a literal action value', () => {
const result = results[mode].at(-1)!;
expect(result.error).toBeUndefined();
expect(result.commandArgs).toEqual(['clipboard.write', '--', '--help']);
expect(result.events).toEqual([
{ event: 'prerun', id: 'simulator:control' },
{ event: 'run', id: 'simulator:control' },
{ event: 'postrun', id: 'simulator:control' },
]);
}
);

it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
'shows ordinary help without starting the command for an incomplete flag: $argv',
({ index }) => {
const { stdout, events, error } = results[index + actionHelp.length];
expect(error).toBeUndefined();
expect(stdout).toContain('USAGE');
expect(events).toEqual([]);
}
);
});
});
}
);
Loading
Loading