From c55c61d2b7a708e8c7f5afe5439f4278a9cb1994 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Fri, 30 Oct 2026 01:23:56 +0000 Subject: [PATCH] feat!: structured CLI errors with distinct exit codes Every mm failure prints one JSON line on stdout with error.code, message, optional hint and retryable, and exits with a code per error class (2 usage, 3 session/daemon, 4 target, 5 timeout, 6 platform, 1 other). The daemon's error.code now reaches the caller. All non-zero exits go through fail(). Click/type timeouts say state unknown. launch keeps any consumer MM_* code. --- CHANGELOG.md | 1 + README.md | 54 +++- SKILL.md | 38 ++- src/cli/mm.test.ts | 459 +++++++++++++++++++------------ src/cli/mm.ts | 481 +++++++++++++++++++-------------- src/tools/interaction.test.ts | 3 + src/tools/interaction.ts | 4 +- src/tools/launch.test.ts | 24 +- src/tools/launch.ts | 13 +- src/tools/types/errors.test.ts | 97 +++++++ src/tools/types/errors.ts | 175 +++++++++++- 11 files changed, 955 insertions(+), 394 deletions(-) create mode 100644 src/tools/types/errors.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5250d..4caf36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `mm` failures are structured. A failed command prints one JSON line on stdout, `{"ok":false,"error":{"code","message","hint"?,"retryable"},"meta"?}`, instead of `Error: ` on stderr, and exits with a code per error class: 2 usage/validation, 3 no session or daemon down/lost, 4 target not found or not actionable, 5 timeout, 6 platform/device unavailable, 1 anything else (7 is reserved for a version mismatch). The daemon's `error.code` now reaches the caller. `exitCodeForError`, `isRetryableError`, `defaultHintForError`, `CliExitCodes` and `DaemonErrorCodes` are exported, and `ErrorCodes` adds `MM_BATCH_TIMEOUT`, the `MM_CLIPBOARD_*` codes and the CLI codes `MM_DAEMON_NOT_RUNNING`, `MM_DAEMON_START_FAILED`, `MM_DAEMON_ALREADY_RUNNING`, `MM_DAEMON_CONNECTION_LOST` and `MM_REQUEST_TIMEOUT`. Click and type timeouts say "state unknown". `launch` keeps any `MM_*` code thrown by a consumer, not only known codes. - Mobile `click` never reports a tap that hit something else: device-mcp refuses a tap when another element covers the target, and the click fails with the new `MM_TARGET_OBSCURED` (`MM_TARGET_NOT_VISIBLE` when the target is off screen). `click --dismiss-keyboard` closes a covering keyboard and taps once more (`keyboardDismissed: true`). With an observation, a mobile click result has `screenChanged`. The hit-test needs a device-mcp release with MetaMask/device-mcp#51. - Start the CLI faster: find the project root without running `git` and do not load zod. Load `@metamask/device-mcp` only when a mobile session uses it, so browser daemons start without it. - **BREAKING:** `scroll_to_element` `direction` is now the content direction (`down` reveals content below; it was the finger direction) and defaults to `auto`. The result adds `swipes`, `inViewport` and `bounds`; a target that is found but not brought into view fails with the new `MM_TARGET_NOT_VISIBLE`, and one never found with `MM_TARGET_NOT_FOUND`. The viewport check needs a device-mcp release with MetaMask/device-mcp#50; with device-mcp 0.7.0 only `bounds` is returned. diff --git a/README.md b/README.md index 31348eb..eb18803 100644 --- a/README.md +++ b/README.md @@ -744,10 +744,11 @@ them — for example by refusing `--reinstall` when no `--app-bundle` is supplie or by requiring `--allow-fox-code-mismatch` before replacing an app whose identity differs from the installed one. -When a consumer's `launch()` throws an error carrying a `code` that is a known -[`ErrorCode`](#error-classification), the `launch` tool preserves it instead of -collapsing it into `MM_LAUNCH_FAILED`, so precise device/prerequisite failures -reach the agent intact. +When a consumer's `launch()` throws an error carrying an `MM_*` `code` (a known +[`ErrorCode`](#error-classification) or a consumer-specific one), the `launch` +tool preserves it instead of collapsing it into `MM_LAUNCH_FAILED`, so precise +device/prerequisite failures reach the agent intact. The CLI maps a code it does +not know to exit 1. #### Mobile device actions (iOS/Android only) @@ -842,6 +843,51 @@ Tool errors are classified into specific error codes for structured handling: | `MM_DEVICE_ACTION_FAILED` | A mobile device action (swipe, tap, app control, clipboard, etc.) failed | | `MM_DEVICE_NOT_AVAILABLE` | A mobile-only tool was invoked without a mobile driver capability | | `MM_TOOL_NOT_SUPPORTED_ON_PLATFORM` | Tool gated off the active platform (browser-only tool on mobile, or mobile-only tool on browser) | +| **CLI (set by `mm`, not a tool)** | | +| `MM_DAEMON_NOT_RUNNING` | No daemon, or the daemon refused the connection | +| `MM_DAEMON_START_FAILED` | The daemon did not become ready in time | +| `MM_DAEMON_ALREADY_RUNNING` | `mm serve` while a daemon is running | +| `MM_DAEMON_CONNECTION_LOST` | The connection failed after the request was sent; the action may have run | +| `MM_REQUEST_TIMEOUT` | The CLI stopped waiting for the daemon; the action state is unknown | + +The daemon HTTP server also sets `VALIDATION_ERROR` (schema), `TOOL_NOT_FOUND`, +`SERVER_NOT_STARTED`, `TOOL_EXECUTION_FAILED` and `INTERNAL_ERROR` +(`DaemonErrorCodes`). + +### CLI failure output and exit codes + +A failed `mm` command prints one JSON line on **stdout** and exits non-zero. +stderr is for human logs only (on a terminal it also gets `Error: `). + +```json +{ + "ok": false, + "error": { + "code": "MM_TARGET_NOT_FOUND", + "message": "...", + "hint": "Run `mm describe-screen` to get fresh refs.", + "retryable": false + }, + "meta": { "totalMs": 512 } +} +``` + +`hint` is optional. `retryable` is true only when running the same command +again, unchanged, is safe (the call only reads, or nothing ran); it is false for +click, type and request timeouts because the action state is unknown. `meta` is +present when the daemon answered. `exitCodeForError()` in +`src/tools/types/errors.ts` is the one map from error code to exit code: + +| Exit | Meaning | Codes (examples) | +| ---- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 0 | ok | | +| 1 | anything else (internal error, tool failure, unknown code) | `MM_INTERNAL_ERROR`, `TOOL_EXECUTION_FAILED`, `MM_LAUNCH_FAILED`, `MM_SEED_FAILED` | +| 2 | usage or validation; nothing ran | `MM_INVALID_INPUT`, `MM_INVALID_CONFIG`, `VALIDATION_ERROR`, `TOOL_NOT_FOUND`, `MM_CDP_BLOCKED` | +| 3 | no session, daemon not running, daemon connection lost | `MM_NO_ACTIVE_SESSION`, `MM_PAGE_CLOSED`, `MM_DAEMON_NOT_RUNNING`, `MM_DAEMON_CONNECTION_LOST` | +| 4 | target not found or not actionable | `MM_TARGET_NOT_FOUND`, `MM_TARGET_NOT_VISIBLE`, `MM_TARGET_OBSCURED`, `MM_CLICK_FAILED`, `MM_TAB_NOT_FOUND` | +| 5 | timeout (click, type and request timeouts: action state unknown) | `MM_WAIT_TIMEOUT`, `MM_CLICK_TIMEOUT`, `MM_TYPE_TIMEOUT`, `MM_REQUEST_TIMEOUT`, `MM_BATCH_TIMEOUT` | +| 6 | platform, device, Metro or Hermes not available | `MM_DEVICE_ACTION_FAILED`, `MM_DEVICE_NOT_AVAILABLE`, `MM_HERMES_FAILED`, `MM_TOOL_NOT_SUPPORTED_ON_PLATFORM` | +| 7 | daemon version mismatch (reserved) | | ## Development diff --git a/SKILL.md b/SKILL.md index f5a6811..0abff88 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,7 +25,7 @@ Cleanup also disposes the session driver's owned resources after consumer app/se - **Always `describe-screen` before interacting.** Refs like `e1`, `e2` are ephemeral — use the refs from the latest response (`describe-screen` or the `observations` of your last action). - **Always `describe-screen` after interacting** — OR use inline `observations` from mutating tool responses. Mutating tools (click, type, navigate, etc.) return an `observations` object with fresh `testIds` and `a11y` refs. You can use these refs directly for the next interaction without calling `describe-screen`. Call `describe-screen` when you need the full tree or screenshots, and `get-state` when you need app state (observations carry none). - **One target per command.** Specify exactly ONE of: a11y ref (`e5`), testId, or CSS selector. -- **Errors are structured.** Check the `error.code` field to decide recovery strategy (see Error Codes below). +- **Errors are structured.** A failed command prints one JSON line on stdout (`{"ok":false,"error":{"code","message","hint"?,"retryable"}}`) and exits non-zero. Branch on `error.code` or the exit code (see Error Codes below), not on stderr text. Never repeat a click or type that failed with exit 5 before you check the screen. ## Observation Behavior @@ -685,7 +685,35 @@ Every interaction command (`click`, `type`, `get-text`, `wait-for`) needs a targ ## Error Codes -When a command fails, the response includes `error.code`. Use this to decide what to do: +When a command fails, `mm` prints one JSON line on **stdout** and exits non-zero: + +```json +{ + "ok": false, + "error": { + "code": "MM_TARGET_NOT_FOUND", + "message": "...", + "hint": "Run `mm describe-screen` to get fresh refs.", + "retryable": false + }, + "meta": { "totalMs": 512 } +} +``` + +`hint` (optional) says what to do next. `retryable: true` means you can run the same command again unchanged. It is never true for a click, type or request timeout: the action state is unknown, so check the screen first. + +| Exit | Meaning | Do | +| ---- | ------------------------------------------------------ | --------------------------------------------------------------------- | +| 0 | ok | | +| 1 | anything else (internal, tool failure) | Read `error.message` | +| 2 | usage or validation; nothing ran | Fix the command | +| 3 | no session, daemon not running, daemon connection lost | `mm launch`; after `MM_DAEMON_CONNECTION_LOST` check the screen first | +| 4 | target not found or not actionable | `mm describe-screen`, then re-target / scroll / dismiss the keyboard | +| 5 | timeout (click/type/request: action state unknown) | `mm describe-screen` before any retry | +| 6 | platform, device, Metro or Hermes not available | Check the device, Metro and the build | +| 7 | daemon version mismatch (reserved) | | + +Use `error.code` to decide what to do: | Code | Meaning | Recovery | | ----------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | @@ -700,7 +728,7 @@ When a command fails, the response includes `error.code`. Use this to decide wha | `MM_CLICK_FAILED` | Click failed after finding element | Element may be obscured; try waiting or scrolling | | `MM_TARGET_OBSCURED` | Mobile: another element (keyboard, sheet, overlay) covers the target; nothing was tapped | Keyboard: `mm dismiss-keyboard` or `mm click ... --dismiss-keyboard`. Overlay: close it first | | `MM_TARGET_NOT_VISIBLE` | Mobile: target found but outside the screen | `mm scroll-to-element `, then click | -| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung) | Run `mm describe-screen` to verify if click completed; retry with `--timeout` or different approach | +| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung); state unknown | Run `mm describe-screen` to check if the click ran before you retry | | `MM_TYPE_FAILED` | Type failed after finding element | Element may not be an input; verify with describe-screen | | `MM_TYPE_TIMEOUT` | Fill action timed out | Run `mm describe-screen` to verify state; retry with `--timeout` | | `MM_GETTEXT_FAILED` | getText operational failure (non-timeout) | Element may be detached; run `mm describe-screen` and re-target | @@ -732,6 +760,10 @@ When a command fails, the response includes `error.code`. Use this to decide wha | `MM_DEVICE_ACTION_FAILED` | A mobile device action failed | Check the target/args; ensure the device/emulator is responsive | | `MM_DEVICE_NOT_AVAILABLE` | Mobile-only tool invoked without a mobile driver | Launch a mobile (iOS/Android) session first | | `MM_TOOL_NOT_SUPPORTED_ON_PLATFORM` | Tool gated off the active platform | Use the tool on its supported platform (browser vs mobile) | +| `MM_DAEMON_NOT_RUNNING` | No daemon, or it refused the connection | Run `mm launch` | +| `MM_DAEMON_CONNECTION_LOST` | Connection failed after the request was sent | The action may have run: `mm describe-screen` before you retry | +| `MM_REQUEST_TIMEOUT` | The CLI stopped waiting for the daemon | Action state unknown: `mm describe-screen` before you retry | +| `VALIDATION_ERROR` | Input failed the tool schema | Fix the arguments (exit 2) | ## Available Contracts (E2E only) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index 8db49fa..d3074b1 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -15,7 +15,10 @@ import { extractObserveFlag, extractPrettyFlag, extractProjectFlag, + fail, + failUsage, formatJsonOutput, + getNetworkErrorCode, resolvePretty, setObserveLevel, setPrettyOutput, @@ -95,6 +98,44 @@ vi.mock('cosmiconfig', () => ({ let exitSpy: MockInstance; let stderrSpy: MockInstance; let stdoutSpy: MockInstance; + +type FailureLine = { + ok: false; + error: { code: string; message: string; hint?: string; retryable: boolean }; + meta?: unknown; +}; + +/** + * Returns the JSON failure lines the CLI printed on stdout. + * + * @returns The parsed `{"ok":false,...}` lines, in order. + */ +function failureLines(): FailureLine[] { + return stdoutSpy.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('{"ok":false')) + .map((line) => JSON.parse(line) as FailureLine); +} + +/** + * Asserts that the CLI failed with one JSON line on stdout and exited with + * the given code. + * + * @param message - The expected message, or an asymmetric matcher. + * @param exitCode - The expected process exit code. + * @returns The failure line. + */ +function expectFailure(message: unknown, exitCode: number): FailureLine { + const lines = failureLines(); + expect(lines.length).toBeGreaterThan(0); + const line = lines[0]; + expect(line.ok).toBe(false); + expect(line.error.message).toStrictEqual(message); + expect(typeof line.error.code).toBe('string'); + expect(typeof line.error.retryable).toBe('boolean'); + expect(exitSpy).toHaveBeenCalledWith(exitCode); + return line; +} const mockCreateRequire = vi.mocked(createRequire); const mockResolveRuntime = vi.fn(); @@ -167,18 +208,14 @@ describe('extractProjectFlag', () => { expect(() => extractProjectFlag(['--project'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --project requires a path value\n', - ); + expectFailure('--project requires a path value', 2); }); it('exits when --project value starts with --', () => { expect(() => extractProjectFlag(['--project', '--force'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --project requires a path value\n', - ); + expectFailure('--project requires a path value', 2); }); }); @@ -208,9 +245,7 @@ describe('extractObserveFlag', () => { [['--observe', '--force']], ])('exits on a missing or invalid value: %j', (argv) => { expect(() => extractObserveFlag(argv)).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --observe requires one of: none, diff, full\n', - ); + expectFailure('--observe requires one of: none, diff, full', 2); }); }); @@ -360,9 +395,7 @@ describe('resolveTargetFromArgs', () => { expect(() => resolveTargetFromArgs(['--selector'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --selector requires a value\n', - ); + expectFailure('--selector requires a value', 2); }); it('exits when --selector value starts with --', () => { @@ -375,16 +408,12 @@ describe('resolveTargetFromArgs', () => { expect(() => resolveTargetFromArgs(['--testid'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --testid requires a value\n', - ); + expectFailure('--testid requires a value', 2); }); it('exits when no target provided', () => { expect(() => resolveTargetFromArgs([])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: element target is required\n', - ); + expectFailure('element target is required', 2); }); it('skips flag-value pairs and finds positional target', () => { @@ -442,9 +471,7 @@ describe('resolveWithinFromArgs', () => { expect(() => resolveWithinFromArgs(['--within'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --within requires a value\n', - ); + expectFailure('--within requires a value', 2); }); it('exits when --within value starts with --', () => { @@ -496,6 +523,33 @@ describe('getPositionalArgs', () => { }); }); +describe('getNetworkErrorCode', () => { + it('reads the code from error.cause (Node fetch)', () => { + expect( + getNetworkErrorCode( + new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }), + ), + ).toBe('ECONNREFUSED'); + }); + + it('reads the code from error.code', () => { + expect( + getNetworkErrorCode(Object.assign(new Error('x'), { code: 'EPIPE' })), + ).toBe('EPIPE'); + }); + + it('falls back to the code in the message', () => { + expect(getNetworkErrorCode(new Error('connect ECONNRESET 1.2.3.4'))).toBe( + 'ECONNRESET', + ); + }); + + it('returns undefined when there is no code', () => { + expect(getNetworkErrorCode(new TypeError('fetch failed'))).toBeUndefined(); + expect(getNetworkErrorCode(undefined)).toBeUndefined(); + }); +}); + describe('isTransientError', () => { it('returns true for ECONNREFUSED', () => { expect(isTransientError(new Error('ECONNREFUSED'))).toBe(true); @@ -570,9 +624,7 @@ describe('parseJsonArgument', () => { expect(() => parseJsonArgument('{bad}', 'test')).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('invalid JSON for test'), - ); + expectFailure(expect.stringContaining('invalid JSON for test'), 2); }); }); @@ -649,8 +701,9 @@ describe('readSelfOnboardingInput', () => { await expect(readSelfOnboardingInput(source)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --self-onboarding requires the secret recovery phrase on stdin\n', + expectFailure( + '--self-onboarding requires the secret recovery phrase on stdin', + 2, ); const written = stderrSpy.mock.calls .map((call) => String(call[0])) @@ -662,8 +715,9 @@ describe('readSelfOnboardingInput', () => { await expect( readSelfOnboardingInput(Readable.from(['one two three\n'])), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: self-onboarding requires a 12 or 24 word secret recovery phrase\n', + expectFailure( + 'self-onboarding requires a 12 or 24 word secret recovery phrase', + 2, ); }); @@ -671,9 +725,7 @@ describe('readSelfOnboardingInput', () => { await expect( readSelfOnboardingInput(Readable.from([`${RECOVERY_PHRASE}\nshort\n`])), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: self-onboarding password must be at least 8 characters\n', - ); + expectFailure('self-onboarding password must be at least 8 characters', 2); const written = stderrSpy.mock.calls .map((call) => String(call[0])) .join(''); @@ -687,8 +739,9 @@ describe('readSelfOnboardingInput', () => { Readable.from([`${RECOVERY_PHRASE}\n${VAULT_PASSWORD}\nextra\n`]), ), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: self-onboarding stdin takes the phrase and an optional password line only\n', + expectFailure( + 'self-onboarding stdin takes the phrase and an optional password line only', + 2, ); const written = stderrSpy.mock.calls .map((call) => String(call[0])) @@ -746,9 +799,7 @@ describe('parseLaunchArgs', () => { it('exits for --state without value', () => { expect(() => parseLaunchArgs(['--state'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --state requires a value (default|onboarding|custom)\n', - ); + expectFailure('--state requires a value (default|onboarding|custom)', 2); }); it('exits for --state with flag as value', () => { @@ -761,9 +812,7 @@ describe('parseLaunchArgs', () => { expect(() => parseLaunchArgs(['--extension-path'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --extension-path requires a value\n', - ); + expectFailure('--extension-path requires a value', 2); }); it('parses --context value', () => { @@ -783,9 +832,7 @@ describe('parseLaunchArgs', () => { it('exits for --context without value', () => { expect(() => parseLaunchArgs(['--context'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --context requires a value (e2e|prod)\n', - ); + expectFailure('--context requires a value (e2e|prod)', 2); }); it('exits for --context with flag as value', () => { @@ -824,9 +871,7 @@ describe('parseLaunchArgs', () => { it('exits for --platform without value', () => { expect(() => parseLaunchArgs(['--platform'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --platform requires a value (browser|ios|android)\n', - ); + expectFailure('--platform requires a value (browser|ios|android)', 2); }); it('exits for --platform with flag as value', () => { @@ -837,9 +882,7 @@ describe('parseLaunchArgs', () => { it('exits for --device-id without value', () => { expect(() => parseLaunchArgs(['--device-id'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --device-id requires a value\n', - ); + expectFailure('--device-id requires a value', 2); }); it('parses mobile-specific launch flags', () => { @@ -886,9 +929,7 @@ describe('parseLaunchArgs', () => { expect(() => parseLaunchArgs(['--app-bundle'])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --app-bundle requires a path\n', - ); + expectFailure('--app-bundle requires a path', 2); }); it('exits for --app-bundle with flag as value', () => { @@ -903,9 +944,7 @@ describe('parseLaunchArgs', () => { expect(() => parseLaunchArgs(['--metro-port', port])).toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --metro-port requires a valid port (1-65535)\n', - ); + expectFailure('--metro-port requires a valid port (1-65535)', 2); }, ); @@ -998,8 +1037,9 @@ describe('resolveRuntime', () => { }); expect(() => resolveRuntime('/root', 'tsx')).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( + expectFailure( expect.stringContaining("Runtime 'tsx' is not installed in /root"), + 6, ); }); @@ -1130,8 +1170,9 @@ describe('readDaemonConfig', () => { await expect(readDaemonConfig('/mock/worktree')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - "Error: Unsupported runtime 'swc-node'. Supported runtimes are 'node' and 'tsx'.\n", + expectFailure( + "Unsupported runtime 'swc-node'. Supported runtimes are 'node' and 'tsx'.", + 2, ); }); @@ -1141,9 +1182,7 @@ describe('readDaemonConfig', () => { await expect(readDaemonConfig('/project')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('No mm-client-cli config found'), - ); + expectFailure(expect.stringContaining('No mm-client-cli config found'), 2); }); it('exits when config file is empty', async () => { @@ -1156,9 +1195,7 @@ describe('readDaemonConfig', () => { await expect(readDaemonConfig('/project')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('No mm-client-cli config found'), - ); + expectFailure(expect.stringContaining('No mm-client-cli config found'), 2); }); it('exits when daemon is not configured', async () => { @@ -1171,8 +1208,9 @@ describe('readDaemonConfig', () => { await expect(readDaemonConfig('/project')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( + expectFailure( expect.stringContaining('No daemon entry point configured'), + 2, ); }); }); @@ -1209,9 +1247,7 @@ describe('resolveWorktreeRoot', () => { await expect(resolveWorktreeRoot('/bad/path')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('project path does not exist'), - ); + expectFailure(expect.stringContaining('project path does not exist'), 2); }); it('exits when path is not a directory', async () => { @@ -1223,8 +1259,9 @@ describe('resolveWorktreeRoot', () => { await expect(resolveWorktreeRoot('/some/file.txt')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( + expectFailure( expect.stringContaining('project path is not a directory'), + 2, ); }); @@ -1235,9 +1272,7 @@ describe('resolveWorktreeRoot', () => { await expect(resolveWorktreeRoot('/some/path')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('cannot access project path'), - ); + expectFailure(expect.stringContaining('cannot access project path'), 2); }); it('finds the nearest .git entry above the cwd without running git', async () => { @@ -1323,9 +1358,7 @@ describe('resolveWorktreeRoot', () => { await expect(resolveWorktreeRoot(undefined)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('not in a git repository'), - ); + expectFailure(expect.stringContaining('not in a git repository'), 2); process.env.MM_PROJECT = origEnv; }); @@ -1451,7 +1484,7 @@ describe('sendRequest', () => { await expect( sendRequest(3000, 'POST', '/tool/click', {}), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith('Error: No session\n'); + expectFailure('No session', 1); }); it('exits on ok:false in response body', async () => { @@ -1466,7 +1499,7 @@ describe('sendRequest', () => { await expect( sendRequest(3000, 'POST', '/tool/click', {}), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith('Error: Tool failed\n'); + expectFailure('Tool failed', 1); }); it('retries transient errors', async () => { @@ -1493,9 +1526,35 @@ describe('sendRequest', () => { await expect( sendRequest(3000, 'GET', '/health', null), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('ECONNREFUSED'), + expectFailure(expect.stringContaining('ECONNREFUSED'), 3); + }); + + it('reports a refused connection as MM_DAEMON_NOT_RUNNING, exit 3', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }), + ); + + await expect( + sendRequest(3000, 'GET', '/health', null), + ).rejects.toThrowError('process.exit'); + + const line = expectFailure('fetch failed', 3); + expect(line.error.code).toBe('MM_DAEMON_NOT_RUNNING'); + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it('reports a reset connection as MM_DAEMON_CONNECTION_LOST, exit 3', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new TypeError('fetch failed', { cause: { code: 'ECONNRESET' } }), ); + + await expect( + sendRequest(3000, 'POST', '/tool/click', {}), + ).rejects.toThrowError('process.exit'); + + const line = expectFailure(expect.stringContaining('fetch failed'), 3); + expect(line.error.code).toBe('MM_DAEMON_CONNECTION_LOST'); + expect(line.error.hint).toContain('may have run'); }); it('exits immediately for non-transient errors', async () => { @@ -1506,9 +1565,7 @@ describe('sendRequest', () => { await expect( sendRequest(3000, 'GET', '/health', null), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('some other error'), - ); + expectFailure(expect.stringContaining('some other error'), 1); }); it('exits on request timeout (AbortError)', async () => { @@ -1519,12 +1576,11 @@ describe('sendRequest', () => { await expect(sendRequest(3000, 'POST', '/launch', {})).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('request timed out'), - ); + expectFailure(expect.stringContaining('request timed out'), 5); }); - it('prints plain error message on tool failure', async () => { + it('prints the daemon error code, hint and meta as one JSON line on stdout', async () => { + const meta = { totalMs: 12, queueMs: 0, toolMs: 11, observationMs: 0 }; vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 400, @@ -1534,6 +1590,7 @@ describe('sendRequest', () => { code: 'MM_TARGET_NOT_FOUND', message: 'Element not found', }, + meta, }), } as Response); @@ -1541,7 +1598,119 @@ describe('sendRequest', () => { sendRequest(3000, 'POST', '/tool/click', { testId: 'btn' }), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith('Error: Element not found\n'); + expect(stdoutSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(stdoutSpy.mock.calls[0][0]))).toStrictEqual({ + ok: false, + error: { + code: 'MM_TARGET_NOT_FOUND', + message: 'Element not found', + hint: 'Run `mm describe-screen` to get fresh refs.', + retryable: false, + }, + meta, + }); + expect(exitSpy).toHaveBeenCalledWith(4); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('keeps a hint sent by the daemon', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: false, + error: { code: 'MM_CLICK_FAILED', message: 'x', hint: 'daemon hint' }, + }), + } as Response); + + await expect( + sendRequest(3000, 'POST', '/tool/click', {}), + ).rejects.toThrowError('process.exit'); + + expect(expectFailure('x', 4).error.hint).toBe('daemon hint'); + }); + + it.each([ + ['VALIDATION_ERROR', 2], + ['TOOL_NOT_FOUND', 2], + ['SERVER_NOT_STARTED', 3], + ['MM_NO_ACTIVE_SESSION', 3], + ['MM_TARGET_OBSCURED', 4], + ['MM_CLICK_TIMEOUT', 5], + ['MM_DEVICE_ACTION_FAILED', 6], + ['TOOL_EXECUTION_FAILED', 1], + ['MM_CONSUMER_SPECIFIC_CODE', 1], + ])('maps daemon code %s to exit %i', async (code, exitCode) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ ok: false, error: { code, message: 'm' } }), + } as Response); + + await expect( + sendRequest(3000, 'POST', '/tool/click', {}), + ).rejects.toThrowError('process.exit'); + + expect(expectFailure('m', exitCode).error.code).toBe(code); + }); + + it('uses MM_INTERNAL_ERROR when the daemon sends no code', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ ok: false, error: { message: 'm' } }), + } as Response); + + await expect( + sendRequest(3000, 'POST', '/tool/click', {}), + ).rejects.toThrowError('process.exit'); + + expect(expectFailure('m', 1).error.code).toBe('MM_INTERNAL_ERROR'); + }); + + it('marks a request timeout as state unknown, not retryable, exit 5', async () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + vi.spyOn(globalThis, 'fetch').mockRejectedValue(abortError); + + await expect( + sendRequest(3000, 'POST', '/tool/click', {}, { requestTimeoutMs: 500 }), + ).rejects.toThrowError('process.exit'); + + const line = expectFailure( + 'request timed out after 500ms; state unknown', + 5, + ); + expect(line.error.code).toBe('MM_REQUEST_TIMEOUT'); + expect(line.error.retryable).toBe(false); + expect(line.error.hint).toContain('describe-screen'); + }); + + it('also writes the message to stderr when stderr is a terminal', async () => { + const isTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + try { + expect(() => fail('MM_INVALID_INPUT', 'bad flag')).toThrowError( + 'process.exit', + ); + expect(stderrSpy).toHaveBeenCalledWith('Error: bad flag\n'); + expectFailure('bad flag', 2); + } finally { + if (isTTY) { + Object.defineProperty(process.stderr, 'isTTY', isTTY); + } else { + delete (process.stderr as { isTTY?: boolean }).isTTY; + } + } + }); + + it('indents the failure line with pretty output', () => { + setPrettyOutput(true); + + expect(() => failUsage('bad')).toThrowError('process.exit'); + + expect(String(stdoutSpy.mock.calls[0][0])).toContain('\n "error"'); }); it('falls back to data when no result key', async () => { @@ -1566,7 +1735,7 @@ describe('sendRequest', () => { await expect(sendRequest(3000, 'POST', '/tool/x', {})).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith('Error: Request failed\n'); + expectFailure('Request failed', 1); }); it('reaches the final fallback after repeated transient failures when exit does not throw', async () => { @@ -1578,10 +1747,16 @@ describe('sendRequest', () => { await sendRequest(3000, 'GET', '/health', null); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('request failed after 4 attempts'), - ); - expect(process.exit).toHaveBeenCalledWith(1); + // exit does not throw here, so every attempt reaches fail(); the last + // line is the final fallback. + const lines = failureLines(); + expect(lines[lines.length - 1].error).toStrictEqual({ + code: 'MM_DAEMON_NOT_RUNNING', + message: expect.stringContaining('request failed after 4 attempts'), + hint: 'Run `mm launch` to start.', + retryable: false, + }); + expect(process.exit).toHaveBeenLastCalledWith(3); }); it('merges observations into object result', async () => { @@ -1798,9 +1973,7 @@ describe('routeCommand', () => { await expect(routeCommand('click', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm click'), - ); + expectFailure(expect.stringContaining('Usage: mm click'), 2); }); it('routes type with ref and text', async () => { @@ -1863,7 +2036,7 @@ describe('routeCommand', () => { await expect(routeCommand('type', ['e1'], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith('Usage: mm type \n'); + expectFailure('Usage: mm type ', 2); }); it('routes describe-screen', async () => { @@ -2012,9 +2185,7 @@ describe('routeCommand', () => { await expect( routeCommand('set-context', ['other'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Usage: mm set-context \n', - ); + expectFailure('Usage: mm set-context ', 2); }); it('exits when set-context has no value', async () => { @@ -2363,9 +2534,7 @@ describe('routeCommand', () => { await expect( routeCommand('run-steps', ['{bad json}'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('invalid JSON'), - ); + expectFailure(expect.stringContaining('invalid JSON'), 2); }); it('routes mock-network add with a JSON rule', async () => { @@ -2440,18 +2609,14 @@ describe('routeCommand', () => { await expect( routeCommand('mock-network', ['add'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm mock-network add'), - ); + expectFailure(expect.stringContaining('Usage: mm mock-network add'), 2); }); it('exits when mock-network has an unknown action', async () => { await expect( routeCommand('mock-network', ['unknown'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm mock-network'), - ); + expectFailure(expect.stringContaining('Usage: mm mock-network'), 2); }); it('routes cdp with method and params', async () => { @@ -2503,18 +2668,14 @@ describe('routeCommand', () => { await expect(routeCommand('cdp', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm cdp'), - ); + expectFailure(expect.stringContaining('Usage: mm cdp'), 2); }); it('exits when cdp has invalid JSON params', async () => { await expect( routeCommand('cdp', ['Runtime.evaluate', '{bad json}'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('invalid JSON'), - ); + expectFailure(expect.stringContaining('invalid JSON'), 2); }); it('routes cdp with --target android-webview and --url-filter', async () => { @@ -2560,9 +2721,7 @@ describe('routeCommand', () => { await expect( routeCommand('cdp', ['Runtime.evaluate', '--target', 'bogus'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('--target must be'), - ); + expectFailure(expect.stringContaining('--target must be'), 2); }); it('routes scroll-to-element with a11y ref', async () => { @@ -2607,9 +2766,7 @@ describe('routeCommand', () => { await expect( routeCommand('scroll-to-element', [], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm scroll-to-element'), - ); + expectFailure(expect.stringContaining('Usage: mm scroll-to-element'), 2); }); it('routes device-swipe with positional direction', async () => { @@ -2654,9 +2811,7 @@ describe('routeCommand', () => { await expect(routeCommand('device-swipe', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm device-swipe'), - ); + expectFailure(expect.stringContaining('Usage: mm device-swipe'), 2); }); it('routes long-press with a11y ref', async () => { @@ -2683,9 +2838,7 @@ describe('routeCommand', () => { await expect(routeCommand('long-press', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm long-press'), - ); + expectFailure(expect.stringContaining('Usage: mm long-press'), 2); }); it('routes tap-coordinates with positionals', async () => { @@ -2712,9 +2865,7 @@ describe('routeCommand', () => { await expect( routeCommand('tap-coordinates', ['100'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm tap-coordinates'), - ); + expectFailure(expect.stringContaining('Usage: mm tap-coordinates'), 2); }); it('routes dismiss-keyboard', async () => { @@ -2763,9 +2914,7 @@ describe('routeCommand', () => { await expect(routeCommand('open-app', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm open-app'), - ); + expectFailure(expect.stringContaining('Usage: mm open-app'), 2); }); it('routes close-app with bundleId', async () => { @@ -2782,9 +2931,7 @@ describe('routeCommand', () => { await expect(routeCommand('close-app', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm close-app'), - ); + expectFailure(expect.stringContaining('Usage: mm close-app'), 2); }); it('routes press-button with button name', async () => { @@ -2801,9 +2948,7 @@ describe('routeCommand', () => { await expect(routeCommand('press-button', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm press-button'), - ); + expectFailure(expect.stringContaining('Usage: mm press-button'), 2); }); it('routes device-context list', async () => { @@ -2830,9 +2975,7 @@ describe('routeCommand', () => { await expect( routeCommand('device-context', ['bogus'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm device-context'), - ); + expectFailure(expect.stringContaining('Usage: mm device-context'), 2); }); it('exits when device-context switch has no name', async () => { @@ -2865,9 +3008,7 @@ describe('routeCommand', () => { await expect( routeCommand('device-clipboard', ['bogus'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm device-clipboard'), - ); + expectFailure(expect.stringContaining('Usage: mm device-clipboard'), 2); }); it('exits when device-clipboard write has no text', async () => { @@ -2917,9 +3058,7 @@ describe('routeCommand', () => { await expect( routeCommand('screen-recording', ['bogus'], 3000), ).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Usage: mm screen-recording'), - ); + expectFailure(expect.stringContaining('Usage: mm screen-recording'), 2); }); it('routes device-logs with no args', async () => { @@ -2959,9 +3098,7 @@ describe('routeCommand', () => { await expect(routeCommand('unknown-cmd', [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining("unknown command 'unknown-cmd'"), - ); + expectFailure(expect.stringContaining("unknown command 'unknown-cmd'"), 2); }); it.each([ @@ -2973,9 +3110,7 @@ describe('routeCommand', () => { await expect(routeCommand(command, [], 3000)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining(`unknown command '${command}'`), - ); + expectFailure(expect.stringContaining(`unknown command '${command}'`), 2); expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); @@ -3082,9 +3217,7 @@ describe('discoverDaemon', () => { ); expect(removeDaemonState).toHaveBeenCalledWith('/root'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('no daemon running'), - ); + expectFailure(expect.stringContaining('no daemon running'), 3); }); it('exits for non-auto-start commands when no daemon', async () => { @@ -3094,9 +3227,7 @@ describe('discoverDaemon', () => { await expect(discoverDaemon('/root', 'status')).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('no daemon running'), - ); + expectFailure(expect.stringContaining('no daemon running'), 3); }); }); @@ -3217,9 +3348,7 @@ describe('main', () => { process.argv = ['node', 'mm', 'serve']; await expect(main()).rejects.toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('daemon already running'), - ); + expectFailure(expect.stringContaining('daemon already running'), 1); process.argv = origArgv; }); @@ -3716,9 +3845,7 @@ describe('handleServe', () => { await expect(handleServe('/root', false)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('daemon already running'), - ); + expectFailure(expect.stringContaining('daemon already running'), 1); }); it('starts daemon in background mode', async () => { @@ -3915,9 +4042,7 @@ describe('handleStop', () => { await expect(handleStop('/root', false)).rejects.toThrowError( 'process.exit', ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('mm stop --force'), - ); + expectFailure(expect.stringContaining('mm stop --force'), 3); }); it('removes stale state file with --force', async () => { diff --git a/src/cli/mm.ts b/src/cli/mm.ts index e3cfc51..6836c03 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -15,6 +15,12 @@ import { releaseStartupLock, removeDaemonState, } from '../server/daemon-state.js'; +import { + defaultHintForError, + ErrorCodes, + exitCodeForError, + isRetryableError, +} from '../tools/types/errors.js'; import { BEST_EFFORT_CLEANUP_TIMEOUT_MS } from '../tools/utils/constants.js'; import type { DaemonState } from '../types/http.js'; import { computeTimeoutBudget } from '../utils/timeout-budget.js'; @@ -60,6 +66,86 @@ type RuntimeCommand = { getArgs: (daemonPath: string) => string[]; }; +/** + * An error with a code, thrown by the CLI or rebuilt from a daemon error + * response. {@link fail} prints it. + */ +export class CliError extends Error { + readonly code: string; + + readonly hint: string | undefined; + + readonly meta: unknown; + + /** + * @param code - The error code (an `ErrorCodes` value, a daemon code or a + * consumer `MM_*` code). + * @param message - A human-readable description. + * @param options - Optional details. + * @param options.hint - What to do next. + * @param options.meta - The daemon's request timing, when it answered. + */ + constructor( + code: string, + message: string, + options?: { hint?: string; meta?: unknown }, + ) { + super(message); + this.name = 'CliError'; + this.code = code; + this.hint = options?.hint; + this.meta = options?.meta; + } +} + +/** + * Ends the CLI with a failure: prints one JSON line on stdout, + * `{"ok":false,"error":{"code","message","hint"?,"retryable"},"meta"?}`, and + * exits with the code that {@link exitCodeForError} gives for `code`. On a + * terminal stderr it also prints `Error: ` for the human reader. + * Every non-zero exit of the CLI goes through this function. + * + * @param code - The error code. + * @param message - A human-readable description. + * @param options - Optional details. + * @param options.hint - What to do next. Defaults to the code's default hint. + * @param options.meta - The daemon's request timing, when it answered. + * @returns Never; the process exits. + */ +export function fail( + code: string, + message: string, + options?: { hint?: string; meta?: unknown }, +): never { + const hint = options?.hint ?? defaultHintForError(code); + const body = { + ok: false, + error: { + code, + message, + ...(hint === undefined ? {} : { hint }), + retryable: isRetryableError(code), + }, + ...(options?.meta === undefined ? {} : { meta: options.meta }), + }; + process.stdout.write(`${formatJsonOutput(body)}\n`); + if (process.stderr.isTTY) { + process.stderr.write(`Error: ${message}\n`); + } + return process.exit(exitCodeForError(code)); +} + +/** + * Ends the CLI with a usage or validation failure (`MM_INVALID_INPUT`, + * exit 2). + * + * @param message - What is wrong, or the usage line. + * @returns Never; the process exits. + */ +export function failUsage(message: string): never { + return fail(ErrorCodes.MM_INVALID_INPUT, message); +} + /** * Extracts and consumes the `--project ` flag from argv, returning * the remaining args and the extracted project path (if any). @@ -77,8 +163,7 @@ export function extractProjectFlag(argv: string[]): { } const value = argv[idx + 1]; if (!value || value.startsWith('--')) { - process.stderr.write('Error: --project requires a path value\n'); - process.exit(1); + failUsage('--project requires a path value'); } const remaining = [...argv.slice(0, idx), ...argv.slice(idx + 2)]; return { args: remaining, projectPath: value }; @@ -101,10 +186,7 @@ export function extractObserveFlag(argv: string[]): { } const value = argv[idx + 1]; if (!OBSERVE_LEVELS.includes(value as ObserveLevel)) { - process.stderr.write( - `Error: --observe requires one of: ${OBSERVE_LEVELS.join(', ')}\n`, - ); - process.exit(1); + failUsage(`--observe requires one of: ${OBSERVE_LEVELS.join(', ')}`); } const remaining = [...argv.slice(0, idx), ...argv.slice(idx + 2)]; return { args: remaining, observe: value as ObserveLevel }; @@ -280,21 +362,16 @@ export async function resolveWorktreeRoot( try { real = await fs.realpath(resolved); } catch { - process.stderr.write(`Error: project path does not exist: ${resolved}\n`); - process.exit(1); + failUsage(`project path does not exist: ${resolved}`); } try { const stat = await fs.stat(real); if (!stat.isDirectory()) { - process.stderr.write( - `Error: project path is not a directory: ${real}\n`, - ); - process.exit(1); + failUsage(`project path is not a directory: ${real}`); } } catch { - process.stderr.write(`Error: cannot access project path: ${real}\n`); - process.exit(1); + failUsage(`cannot access project path: ${real}`); } return real; @@ -312,10 +389,9 @@ export async function resolveWorktreeRoot( .toString() .trim(); } catch { - process.stderr.write( - 'Error: not in a git repository. Use --project or set MM_PROJECT to target a project.\n', + return failUsage( + 'not in a git repository. Use --project or set MM_PROJECT to target a project.', ); - return process.exit(1); } } @@ -403,8 +479,7 @@ export function resolveWithinFromArgs( } const val = args[withinIdx + 1]; if (!val || val.startsWith('--')) { - process.stderr.write('Error: --within requires a value\n'); - process.exit(1); + failUsage('--within requires a value'); } // "testid:value" → testId, "selector:value" → selector, otherwise auto-detect @@ -433,8 +508,7 @@ export function resolveTargetFromArgs( if (selectorIdx >= 0) { const val = args[selectorIdx + 1]; if (!val || val.startsWith('--')) { - process.stderr.write('Error: --selector requires a value\n'); - process.exit(1); + failUsage('--selector requires a value'); } return { selector: val }; } @@ -443,16 +517,14 @@ export function resolveTargetFromArgs( if (testIdIdx >= 0) { const val = args[testIdIdx + 1]; if (!val || val.startsWith('--')) { - process.stderr.write('Error: --testid requires a value\n'); - process.exit(1); + failUsage('--testid requires a value'); } return { testId: val }; } const target = getPositionalTarget(args); if (!target) { - process.stderr.write('Error: element target is required\n'); - process.exit(1); + failUsage('element target is required'); } return /^e[0-9]+$/u.test(target) ? { a11yRef: target } : { testId: target }; } @@ -578,10 +650,9 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm click [--timeout ] [--selector ] [--testid ] [--within ] [--dismiss-keyboard]\n', + failUsage( + 'Usage: mm click [--timeout ] [--selector ] [--testid ] [--within ] [--dismiss-keyboard]', ); - process.exit(1); } await sendInteractionRequest( port, @@ -603,17 +674,15 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm type [--timeout ] [--selector ] [--testid ] [--within ]\n', + failUsage( + 'Usage: mm type [--timeout ] [--selector ] [--testid ] [--within ]', ); - process.exit(1); } const text = hasExplicitTypeTarget ? typePositionals[0] : typePositionals[1]; if (text === undefined) { - process.stderr.write('Usage: mm type \n'); - process.exit(1); + failUsage('Usage: mm type '); } await sendInteractionRequest(port, '/tool/type', args, { text }); break; @@ -625,10 +694,9 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm get-text [--timeout ] [--selector ] [--testid ] [--within ]\n', + failUsage( + 'Usage: mm get-text [--timeout ] [--selector ] [--testid ] [--within ]', ); - process.exit(1); } await sendInteractionRequest(port, '/tool/get_text', args); break; @@ -649,18 +717,16 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm wait-for [--timeout ] [--selector ] [--testid ] [--within ]\n', + failUsage( + 'Usage: mm wait-for [--timeout ] [--selector ] [--testid ] [--within ]', ); - process.exit(1); } await sendInteractionRequest(port, '/tool/wait_for', args); break; } case 'navigate': if (!args[0]) { - process.stderr.write('Usage: mm navigate \n'); - process.exit(1); + failUsage('Usage: mm navigate '); } await sendRequest(port, 'POST', '/tool/navigate', { screen: 'url', @@ -683,8 +749,7 @@ export async function routeCommand( break; case 'set-context': if (!args[0] || (args[0] !== 'e2e' && args[0] !== 'prod')) { - process.stderr.write('Usage: mm set-context \n'); - process.exit(1); + failUsage('Usage: mm set-context '); } await sendRequest(port, 'POST', '/tool/set_context', { context: args[0], @@ -712,10 +777,9 @@ export async function routeCommand( !tabRole && !tabUrl ? getPositionalTarget(args) : undefined; const resolvedRole = tabRole ?? positionalRole; if (!resolvedRole && !tabUrl) { - process.stderr.write( - 'Usage: mm switch-to-tab | --role | --url \n', + failUsage( + 'Usage: mm switch-to-tab | --role | --url ', ); - process.exit(1); } await sendRequest(port, 'POST', '/tool/switch_to_tab', { ...(resolvedRole ? { role: resolvedRole } : {}), @@ -727,10 +791,7 @@ export async function routeCommand( const closeRole = parseStringFlag(args, '--role'); const closeUrl = parseStringFlag(args, '--url'); if (!closeRole && !closeUrl) { - process.stderr.write( - 'Usage: mm close-tab --role | --url \n', - ); - process.exit(1); + failUsage('Usage: mm close-tab --role | --url '); } await sendRequest(port, 'POST', '/tool/close_tab', { ...(closeRole ? { role: closeRole } : {}), @@ -741,12 +802,10 @@ export async function routeCommand( case 'clipboard': { const clipAction = args[0]; if (!clipAction || (clipAction !== 'read' && clipAction !== 'write')) { - process.stderr.write('Usage: mm clipboard [text]\n'); - process.exit(1); + failUsage('Usage: mm clipboard [text]'); } if (clipAction === 'write' && !args[1]) { - process.stderr.write('Usage: mm clipboard write \n'); - process.exit(1); + failUsage('Usage: mm clipboard write '); } await sendRequest(port, 'POST', '/tool/clipboard', { action: clipAction, @@ -756,10 +815,7 @@ export async function routeCommand( } case 'seed-contract': { if (!args[0]) { - process.stderr.write( - 'Usage: mm seed-contract [--hardfork ]\n', - ); - process.exit(1); + failUsage('Usage: mm seed-contract [--hardfork ]'); } const hardfork = parseStringFlag(args, '--hardfork'); await sendRequest(port, 'POST', '/tool/seed_contract', { @@ -774,10 +830,9 @@ export async function routeCommand( !a.startsWith('--') && args[args.indexOf(a) - 1] !== '--hardfork', ); if (contractNames.length === 0) { - process.stderr.write( - 'Usage: mm seed-contracts ... [--hardfork ]\n', + failUsage( + 'Usage: mm seed-contracts ... [--hardfork ]', ); - process.exit(1); } const seedHardfork = parseStringFlag(args, '--hardfork'); await sendRequest(port, 'POST', '/tool/seed_contracts', { @@ -788,8 +843,7 @@ export async function routeCommand( } case 'get-contract-address': if (!args[0]) { - process.stderr.write('Usage: mm get-contract-address \n'); - process.exit(1); + failUsage('Usage: mm get-contract-address '); } await sendRequest(port, 'POST', '/tool/get_contract_address', { contractName: args[0], @@ -814,26 +868,16 @@ export async function routeCommand( } case 'run-steps': if (!args[0]) { - process.stderr.write( - 'Usage: mm run-steps \'{"steps":[{"tool":"click","args":{"a11yRef":"e1"}}]}\'\n', + failUsage( + 'Usage: mm run-steps \'{"steps":[{"tool":"click","args":{"a11yRef":"e1"}}]}\'', ); - process.exit(1); - } - try { - await sendRequest( - port, - 'POST', - '/tool/run_steps', - JSON.parse(args[0]) as Record, - ); - } catch (error) { - if (error instanceof SyntaxError) { - process.stderr.write(`Error: invalid JSON — ${error.message}\n`); - process.exit(1); - } - /* istanbul ignore next -- non-SyntaxError path depends on delegated failures */ - throw error; } + await sendRequest( + port, + 'POST', + '/tool/run_steps', + parseJsonArgument(args[0], 'run-steps') as Record, + ); break; case 'mock-network': { await routeMockNetworkCommand(args, port); @@ -842,7 +886,7 @@ export async function routeCommand( case 'cdp': { const cdpMethod = args[0]; if (!cdpMethod) { - process.stderr.write( + failUsage( 'Usage: mm cdp [params-json] [--timeout ] [--target hermes|android-webview] [--url-filter ] [--metro-port ] [--app-id ]\n' + ' mm cdp Runtime.evaluate \'{"expression":"document.title"}\'\n' + ' mm cdp Network.enable\n' + @@ -850,9 +894,8 @@ export async function routeCommand( ' # mobile (Hermes): --metro-port / --app-id override the Metro target\n' + ' mm cdp Runtime.evaluate \'{"expression":"1+1","returnByValue":true}\' --app-id io.metamask\n' + ' # mobile (Android WebView): drive the in-app browser DOM\n' + - ' mm cdp Runtime.evaluate \'{"expression":"document.title"}\' --target android-webview\n', + ' mm cdp Runtime.evaluate \'{"expression":"document.title"}\' --target android-webview', ); - process.exit(1); } const cdpTimeout = parseIntFlag(args, '--timeout'); const cdpMetroPort = parseIntFlag(args, '--metro-port'); @@ -864,10 +907,7 @@ export async function routeCommand( cdpTarget !== 'hermes' && cdpTarget !== 'android-webview' ) { - process.stderr.write( - 'Error: --target must be "hermes" or "android-webview"\n', - ); - process.exit(1); + failUsage('--target must be "hermes" or "android-webview"'); } const cdpParamsRaw = args[1] !== undefined && !args[1].startsWith('--') @@ -886,10 +926,7 @@ export async function routeCommand( } cdpParams = parsed as Record; } catch { - process.stderr.write( - `Error: invalid JSON params — expected a JSON object\n`, - ); - process.exit(1); + failUsage(`invalid JSON params — expected a JSON object`); } } await sendRequest(port, 'POST', '/tool/cdp', { @@ -923,10 +960,9 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm scroll-to-element [--selector ] [--testid ] [--direction auto|down|up] [--maxAttempts ]\n', + failUsage( + 'Usage: mm scroll-to-element [--selector ] [--testid ] [--direction auto|down|up] [--maxAttempts ]', ); - process.exit(1); } const scrollDirection = parseStringFlag(args, '--direction'); const scrollMaxAttempts = parseIntFlag(args, '--maxAttempts'); @@ -942,10 +978,9 @@ export async function routeCommand( const swipeDirection = parseStringFlag(args, '--direction') ?? getPositionalTarget(args); if (!swipeDirection) { - process.stderr.write( - 'Usage: mm device-swipe [--startX ] [--startY ] [--distance ]\n', + failUsage( + 'Usage: mm device-swipe [--startX ] [--startY ] [--distance ]', ); - process.exit(1); } const swipeStartX = parseIntFlag(args, '--startX'); const swipeStartY = parseIntFlag(args, '--startY'); @@ -965,10 +1000,9 @@ export async function routeCommand( !args.includes('--selector') && !args.includes('--testid') ) { - process.stderr.write( - 'Usage: mm long-press [--selector ] [--testid ] [--duration ]\n', + failUsage( + 'Usage: mm long-press [--selector ] [--testid ] [--duration ]', ); - process.exit(1); } const pressDuration = parseIntFlag(args, '--duration'); await sendInteractionRequest(port, '/tool/long_press', args, { @@ -983,8 +1017,7 @@ export async function routeCommand( const tapX = tapXFlag ?? parseInt(tapPositionals[0], 10); const tapY = tapYFlag ?? parseInt(tapPositionals[1], 10); if (Number.isNaN(tapX) || Number.isNaN(tapY)) { - process.stderr.write('Usage: mm tap-coordinates \n'); - process.exit(1); + failUsage('Usage: mm tap-coordinates '); } await sendRequest(port, 'POST', '/tool/tap_coordinates', { x: tapX, @@ -1005,22 +1038,19 @@ export async function routeCommand( break; case 'open-app': if (!args[0]) { - process.stderr.write('Usage: mm open-app \n'); - process.exit(1); + failUsage('Usage: mm open-app '); } await sendRequest(port, 'POST', '/tool/open_app', { bundleId: args[0] }); break; case 'close-app': if (!args[0]) { - process.stderr.write('Usage: mm close-app \n'); - process.exit(1); + failUsage('Usage: mm close-app '); } await sendRequest(port, 'POST', '/tool/close_app', { bundleId: args[0] }); break; case 'press-button': if (!args[0]) { - process.stderr.write('Usage: mm press-button