From 96aeb77cbae3a0be92e284563df5930b1ece8139 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 17 Oct 2026 19:29:00 -0700 Subject: [PATCH 1/4] [eas-cli] Fix simulator control action help --- .changeset/simulator-control-help.md | 5 +++++ packages/eas-cli/src/__tests__/help.test.ts | 7 ++++--- packages/eas-cli/src/help.ts | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 .changeset/simulator-control-help.md diff --git a/.changeset/simulator-control-help.md b/.changeset/simulator-control-help.md new file mode 100644 index 0000000000..7172be7659 --- /dev/null +++ b/.changeset/simulator-control-help.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Fix simulator control action help routing and show help when a flag is incomplete. diff --git a/packages/eas-cli/src/__tests__/help.test.ts b/packages/eas-cli/src/__tests__/help.test.ts index d0cb73dc2c..f5254631cc 100644 --- a/packages/eas-cli/src/__tests__/help.test.ts +++ b/packages/eas-cli/src/__tests__/help.test.ts @@ -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'], diff --git a/packages/eas-cli/src/help.ts b/packages/eas-cli/src/help.ts index 455232f8eb..c1af7344d2 100644 --- a/packages/eas-cli/src/help.ts +++ b/packages/eas-cli/src/help.ts @@ -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; } } From 00b0897bcb3b59c2c262dcd5a89e207a7a1b330f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 17 Oct 2026 22:03:13 -0700 Subject: [PATCH 2/4] [eas-cli] Exercise real simulator help lookup and hooks --- .../fixtures/simulator-control-lifecycle.cjs | 32 +++++++++++++++ .../src/__tests__/help.lifecycle.test.ts | 41 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs create mode 100644 packages/eas-cli/src/__tests__/help.lifecycle.test.ts diff --git a/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs b/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs new file mode 100644 index 0000000000..0fc8c4d82b --- /dev/null +++ b/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs @@ -0,0 +1,32 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const root = fs.realpathSync(process.argv[2]); +const cliRequire = require('node:module').createRequire(path.join(root,'packages/eas-cli/package.json')); +cliRequire('ts-node').register({project:path.join(root,'packages/eas-cli/tsconfig.json'),transpileOnly:true}); +const { Config } = cliRequire('@oclif/core'); +const cli = path.join(root, 'packages/eas-cli'); +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 dir=fs.mkdtempSync(path.join(os.tmpdir(),'eas-help-lifecycle-')); +const args=JSON.parse(process.argv[3]); +const events=[]; +globalThis.__helpLifecycleEvents=events; +EasCommand.prototype.run=async function(){ events.push({event:'run',id:this.id}); }; +EasCommand.prototype.finally=async function(){}; +(async()=>{ + try { + fs.writeFileSync(path.join(dir,'package.json'),JSON.stringify({name:'eas-help-lifecycle-fixture',version:'1.0.0',oclif:{bin:'eas',commands:{strategy:'explicit',target:'./commands.cjs'},hooks:{prerun:'./prerun.cjs',postrun:'./postrun.cjs'}}})); + fs.writeFileSync(path.join(dir,'commands.cjs'),`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli,'src/commands/simulator/control'))}).default };`); + for(const event of ['prerun','postrun']) fs.writeFileSync(path.join(dir,`${event}.cjs`),`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`); + process.env.XDG_DATA_HOME=dir; + process.env.XDG_CACHE_HOME=dir; + const config = await Config.load({root:dir}); + // Published manifests discover metadata without loading the command class. + // Clear eager discovery's assignment to exercise that cold-start boundary. + delete Control.id; + await new EasHelp(config).showHelp(args); + console.log('LIFECYCLE_RESULT '+JSON.stringify(events)); + }finally{fs.rmSync(dir,{recursive:true,force:true})} +})().catch(err=>{console.error(err);process.exitCode=1}); diff --git a/packages/eas-cli/src/__tests__/help.lifecycle.test.ts b/packages/eas-cli/src/__tests__/help.lifecycle.test.ts new file mode 100644 index 0000000000..074a5eb539 --- /dev/null +++ b/packages/eas-cli/src/__tests__/help.lifecycle.test.ts @@ -0,0 +1,41 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const root = path.resolve(__dirname, '../../../..'); +const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs'); + +async function runHelpAsync(argv: string[]): Promise<{ stdout: string; events: unknown[] }> { + const { stdout } = await execFileAsync(process.execPath, [fixture, root, JSON.stringify(argv)], { + timeout: 10_000, + }); + const result = stdout.split('LIFECYCLE_RESULT ').at(-1)!; + return { stdout, events: JSON.parse(result) }; +} + +describe('simulator action help through real oclif lookup and hooks', () => { + it.each([ + ['simulator:control', 'rotate', '--help'], + ['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'], + ['simulator:control', '--id', 'session-123', '--help', 'rotate'], + ])('keeps canonical command identity and executes lifecycle hooks: %j', async (...argv) => { + expect((await runHelpAsync(argv)).events).toEqual([ + { event: 'prerun', id: 'simulator:control' }, + { event: 'run', id: 'simulator:control' }, + { event: 'postrun', id: 'simulator:control' }, + ]); + }); + + it.each([ + ['simulator:control', 'screenshot.capture', '--help', '--output'], + ['sim:control', 'rotate', '-h', '--id'], + ])( + 'shows ordinary help without starting the command for an incomplete flag: %j', + async (...argv) => { + const { stdout, events } = await runHelpAsync(argv); + expect(stdout).toContain('USAGE'); + expect(events).toEqual([]); + } + ); +}); From 936fc0b5161de212457db9eb9def23788909563f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 17 Oct 2026 22:15:47 -0700 Subject: [PATCH 3/4] [eas-cli] Batch source-loaded help lifecycle probes --- .../fixtures/simulator-control-lifecycle.cjs | 101 +++++++++++++----- .../src/__tests__/help.lifecycle.test.ts | 60 ++++++----- 2 files changed, 111 insertions(+), 50 deletions(-) diff --git a/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs b/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs index 0fc8c4d82b..47b637f161 100644 --- a/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs +++ b/packages/eas-cli/src/__tests__/fixtures/simulator-control-lifecycle.cjs @@ -1,32 +1,83 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); +const { createRequire } = require('node:module'); + const root = fs.realpathSync(process.argv[2]); -const cliRequire = require('node:module').createRequire(path.join(root,'packages/eas-cli/package.json')); -cliRequire('ts-node').register({project:path.join(root,'packages/eas-cli/tsconfig.json'),transpileOnly:true}); -const { Config } = cliRequire('@oclif/core'); const cli = path.join(root, 'packages/eas-cli'); +const cliRequire = createRequire(path.join(cli, 'package.json')); +cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true }); +const { Config } = 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 dir=fs.mkdtempSync(path.join(os.tmpdir(),'eas-help-lifecycle-')); -const args=JSON.parse(process.argv[3]); -const events=[]; -globalThis.__helpLifecycleEvents=events; -EasCommand.prototype.run=async function(){ events.push({event:'run',id:this.id}); }; -EasCommand.prototype.finally=async function(){}; -(async()=>{ - try { - fs.writeFileSync(path.join(dir,'package.json'),JSON.stringify({name:'eas-help-lifecycle-fixture',version:'1.0.0',oclif:{bin:'eas',commands:{strategy:'explicit',target:'./commands.cjs'},hooks:{prerun:'./prerun.cjs',postrun:'./postrun.cjs'}}})); - fs.writeFileSync(path.join(dir,'commands.cjs'),`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli,'src/commands/simulator/control'))}).default };`); - for(const event of ['prerun','postrun']) fs.writeFileSync(path.join(dir,`${event}.cjs`),`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`); - process.env.XDG_DATA_HOME=dir; - process.env.XDG_CACHE_HOME=dir; - const config = await Config.load({root:dir}); - // Published manifests discover metadata without loading the command class. - // Clear eager discovery's assignment to exercise that cold-start boundary. - delete Control.id; - await new EasHelp(config).showHelp(args); - console.log('LIFECYCLE_RESULT '+JSON.stringify(events)); - }finally{fs.rmSync(dir,{recursive:true,force:true})} -})().catch(err=>{console.error(err);process.exitCode=1}); +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 = []; +globalThis.__helpLifecycleEvents = events; + +// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real. +EasCommand.prototype.run = async function () { + events.push({ event: 'run', id: this.id }); +}; +EasCommand.prototype.finally = async function () {}; + +(async () => { + try { + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name: 'eas-help-lifecycle-fixture', + version: '1.0.0', + oclif: { + bin: 'eas', + commands: { strategy: 'explicit', target: './commands.cjs' }, + hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' }, + }, + }) + ); + fs.writeFileSync( + path.join(directory, 'commands.cjs'), + `exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };` + ); + for (const event of ['prerun', 'postrun']) { + fs.writeFileSync( + path.join(directory, `${event}.cjs`), + `module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };` + ); + } + process.env.XDG_DATA_HOME = directory; + process.env.XDG_CACHE_HOME = directory; + const 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; + const write = process.stdout.write; + let stdout = '', + error; + process.stdout.write = (chunk, encoding, callback) => { + stdout += String(chunk); + const done = typeof encoding === 'function' ? encoding : callback; + done?.(); + return true; + }; + try { + await new EasHelp(config).showHelp(args); + } catch (err) { + error = err.message; + } finally { + process.stdout.write = write; + } + results.push({ stdout, events: events.slice(start), error }); + } + console.log('LIFECYCLE_RESULT ' + JSON.stringify(results)); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +})().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/eas-cli/src/__tests__/help.lifecycle.test.ts b/packages/eas-cli/src/__tests__/help.lifecycle.test.ts index 074a5eb539..ff37bf911e 100644 --- a/packages/eas-cli/src/__tests__/help.lifecycle.test.ts +++ b/packages/eas-cli/src/__tests__/help.lifecycle.test.ts @@ -5,35 +5,45 @@ 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 actionHelp = [ + ['simulator:control', 'rotate', '--help'], + ['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'], + ['simulator:control', '--id', 'session-123', '--help', 'rotate'], +]; +const incompleteFlags = [ + ['simulator:control', 'screenshot.capture', '--help', '--output'], + ['sim:control', 'rotate', '-h', '--id'], +]; +let results: { stdout: string; events: unknown[]; error?: string }[]; -async function runHelpAsync(argv: string[]): Promise<{ stdout: string; events: unknown[] }> { - const { stdout } = await execFileAsync(process.execPath, [fixture, root, JSON.stringify(argv)], { - timeout: 10_000, - }); - const result = stdout.split('LIFECYCLE_RESULT ').at(-1)!; - return { stdout, events: JSON.parse(result) }; -} +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 = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!); +}, 30_000); describe('simulator action help through real oclif lookup and hooks', () => { - it.each([ - ['simulator:control', 'rotate', '--help'], - ['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'], - ['simulator:control', '--id', 'session-123', '--help', 'rotate'], - ])('keeps canonical command identity and executes lifecycle hooks: %j', async (...argv) => { - expect((await runHelpAsync(argv)).events).toEqual([ - { event: 'prerun', id: 'simulator:control' }, - { event: 'run', id: 'simulator:control' }, - { event: 'postrun', id: 'simulator:control' }, - ]); - }); + 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([ + { event: 'prerun', id: 'simulator:control' }, + { event: 'run', id: 'simulator:control' }, + { event: 'postrun', id: 'simulator:control' }, + ]); + } + ); - it.each([ - ['simulator:control', 'screenshot.capture', '--help', '--output'], - ['sim:control', 'rotate', '-h', '--id'], - ])( - 'shows ordinary help without starting the command for an incomplete flag: %j', - async (...argv) => { - const { stdout, events } = await runHelpAsync(argv); + 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([]); } From 55f02a6b062f8e79fe61202bf440d738bd548158 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 26 Oct 2026 16:52:01 -0700 Subject: [PATCH 4/4] [build-tools] Expect headless Appium default on iOS in the launch test #4622 added this test while #4699 added appium:isHeadless to the iOS default capabilities. Each PR passed CI without the other, so main fails. Co-Authored-By: Claude Opus 5.5 --- .../functions/__tests__/startAppiumRemoteSession.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index 2b5c902503..5f45289215 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -266,7 +266,9 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => { '--allow-insecure', '*:session_discovery', '--default-capabilities', - '{"appium:eventTimings":true}', + runtimePlatform === BuildRuntimePlatform.DARWIN + ? '{"appium:eventTimings":true,"appium:isHeadless":true}' + : '{"appium:eventTimings":true}', ], env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME }, logger,