Skip to content
Draft
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** fix: iOS key presses, `back` and text clearing ([#48](https://github.com/MetaMask/device-mcp/pull/48))
- iOS `pressButton('enter')` and `dismissKeyboard()` send the integer HID key code (`idb ui key 40`); `home` and `lock` use `idb ui button`. Both failed before because `idb ui key` takes only integer codes.
- iOS `pressButton('back')` now throws `UNSUPPORTED_ON_IOS` instead of pressing Home (which put the app in the background).
- iOS `dismissKeyboard()` does nothing when no keyboard is shown.
- iOS `typeText` clears the field with Command+A and Backspace through strict execution. Before, the clear failed silently and the text was appended to the old value.
- iOS (idb): read `idb describe --json` once per companion connection for the OS version and window size, instead of before every snapshot, tap and swipe. `getDeviceInfo()` still reads it fresh

## [0.7.0]
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ The `.device-session` file is typically written by the test runner when it creat
| `device_swipe` | Swipe in a direction with optional start coordinates and distance. |
| `device_long_press` | Long press an element for context menus or drag initiation. |
| `device_wait_for` | Poll until an element matching a query appears. |
| `device_press_button` | Press a device button (home/back/enter/lock). |
| `device_press_button` | Press a device button (home/back/enter/lock; no back on iOS). |

### App & Device Control

Expand Down Expand Up @@ -228,11 +228,13 @@ Elements are identified by accessibility attributes — not internal refs. Match
| `device_app_state` | `idb list-apps` / `simctl listapps` | `dumpsys activity` | `mobile: queryAppState` |
| `device_open_app` | `idb launch` / `simctl launch` | `monkey -p` | `mobile: activateApp` |
| `device_close_app` | `idb terminate` / `simctl terminate` | `am force-stop` | `mobile: terminateApp` |
| `device_press_button` | `idb ui key` | `input keyevent` | `mobile: pressButton/Key` |
| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111` | `mobile: hideKeyboard` |
| `device_press_button` | `idb ui key 40` / `idb ui button`¹ | `input keyevent` | `mobile: pressButton/Key` |
| `device_dismiss_keyboard` | `idb ui key 40` if keyboard shown | `input keyevent 111` | `mobile: hideKeyboard` |
| `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` |
| `device_logs` | `idb log` | `logcat` | `mobile: getLog` |

¹ `enter` sends HID key 40 (Return); `home` and `lock` use `idb ui button HOME|LOCK`. iOS has no back button, so `back` fails with `UNSUPPORTED_ON_IOS`. `device_type` clears the focused field first with Command+A (`idb ui key 4 --command`) and Backspace (`idb ui key 42`).

#### Android snapshot on continuously-redrawing screens

`uiautomator dump` calls `UiAutomation.waitForIdle` internally, which never
Expand Down
272 changes: 144 additions & 128 deletions src/backends/idb-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,100 @@ describe('IdbBackend.snapshot accessibility backend selection', () => {
});
});

describe('IdbBackend keys and buttons', () => {
const udid = 'AAAA1111-BBBB-CCCC-DDDD-EEEE2222FFFF';
const idb = '/usr/local/bin/idb';
let backend: IdbBackend;

const idbCalls = (): unknown[][] =>
mockExecStrict.mock.calls
.filter(([cmd, args]) => cmd === idb && args?.[0] === 'ui')
.filter(([, args]) => args?.[1] !== 'describe-all')
.map(([, args]) => args as unknown[]);

const hierarchyWith = (types: string[]): string =>
JSON.stringify([
{
type: 'Application',
AXLabel: 'MetaMask',
frame: { x: 0, y: 0, width: 402, height: 874 },
children: types.map((type) => ({
type,
frame: { x: 0, y: 590, width: 402, height: 284 },
})),
},
]);

beforeEach(() => {
vi.clearAllMocks();
connectionState.alive = true;
mockSupportsApi.mockResolvedValue(false);
mockSupportsNested.mockResolvedValue(true);
mockExec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
mockExecStrict.mockResolvedValue('');
backend = new IdbBackend(udid);
});

it.each([
['enter', ['ui', 'key', '40', '--udid', udid]],
['home', ['ui', 'button', 'HOME', '--udid', udid]],
['lock', ['ui', 'button', 'LOCK', '--udid', udid]],
] as const)('pressButton(%s) sends %j', async (button, args) => {
await backend.pressButton(button);

expect(mockExecStrict).toHaveBeenCalledWith(idb, args, connectionOptions);
expect(idbCalls()).toStrictEqual([args]);
});

it('rejects pressButton(back) without touching the device', async () => {
await expect(backend.pressButton('back')).rejects.toMatchObject({
code: 'UNSUPPORTED_ON_IOS',
message: expect.stringContaining('iOS has no back button'),
});
expect(mockExecStrict).not.toHaveBeenCalled();
expect(connectionMocks.connect).not.toHaveBeenCalled();
});

it('clears with Command+A and Backspace before typing, all strict', async () => {
await backend.typeText('hello world');

expect(idbCalls()).toStrictEqual([
['ui', 'key', '4', '--command', '--udid', udid],
['ui', 'key', '42', '--udid', udid],
['ui', 'text', 'hello world', '--udid', udid],
]);
expect(mockExec).not.toHaveBeenCalledWith(
idb,
expect.anything(),
expect.anything(),
);
});

it('presses Return to dismiss a visible keyboard', async () => {
mockExecStrict.mockImplementation(async (_cmd, args) =>
args?.[1] === 'describe-all' ? hierarchyWith(['Keyboard']) : '',
);

await backend.dismissKeyboard();

expect(idbCalls()).toStrictEqual([['ui', 'key', '40', '--udid', udid]]);
});

it('does nothing when no keyboard is shown', async () => {
// React Native's full-screen KeyboardControllerView is always present
// and must not count as a keyboard.
mockExecStrict.mockImplementation(async (_cmd, args) =>
args?.[1] === 'describe-all'
? hierarchyWith(['KeyboardControllerView'])
: '',
);

await backend.dismissKeyboard();

expect(idbCalls()).toStrictEqual([]);
});
});

describe('IdbBackend companion recovery', () => {
const udid = 'AAAA1111-BBBB-CCCC-DDDD-EEEE2222FFFF';
const hidInvalidated =
Expand Down Expand Up @@ -1581,152 +1675,74 @@ describe('IdbBackend companion recovery', () => {
expect(connectionMocks.connect).toHaveBeenCalledTimes(1);
});

it('returns the first non-strict failure when disposed during a command', async () => {
await backend.ensureConnected();
let finish!: (result: execModule.ExecResult) => void;
mockExec.mockImplementation(async (cmd, args) =>
cmd !== 'xcrun' && args?.[1] === 'key-sequence'
? new Promise((resolve) => {
finish = resolve;
})
: { exitCode: 0, stdout: '', stderr: '' },
const keyCalls = (key: string): unknown[][] =>
mockExecStrict.mock.calls.filter(
([, args]) => args?.[1] === 'key' && args?.[2] === key,
);
const typing = backend.typeText('abc').catch((e: unknown) => e);
await vi.waitFor(() => expect(finish).toBeDefined());

await backend.dispose();
finish({ exitCode: 1, stdout: '', stderr: hidInvalidated });

// The failed key-sequence is returned, not resent; the next idb command
// then fails fast because the backend is disposed.
expect(await typing).toMatchObject({
message: 'IDB backend has been disposed',
});
expect(
mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key-sequence'),
).toHaveLength(1);
expect(connectionMocks.connect).toHaveBeenCalledTimes(1);
});

it('does not resend when disposed after joining a newer companion', async () => {
it('resends the typeText clear when the HID connection was invalidated', async () => {
await backend.ensureConnected();
let fail!: (error: Error) => void;
mockExecStrict.mockReturnValueOnce(
new Promise<string>((_resolve, reject) => {
fail = reject;
}),
);
const tapping = backend.tapCoordinates(1, 2).catch((e: unknown) => e);
await vi.waitFor(() => expect(tapCalls()).toHaveLength(1));
// Another caller replaces the companion while the tap is in flight.
await backend.reconnect();
// Dispose exactly when the failed tap checks the new companion, so
// ensureConnected() itself succeeds.
let disposing: Promise<void> | undefined;
Object.defineProperty(connectionState, 'alive', {
configurable: true,
get: (): boolean => {
disposing ??= backend.dispose();
return true;
},
let failures = 1;
mockExecStrict.mockImplementation(async (_cmd, args) => {
if (args?.[1] === 'key' && args?.[2] === '4' && failures > 0) {
failures -= 1;
throw new Error(hidInvalidated);
}
return '';
});
try {
fail(new Error(hidInvalidated));
expect(await tapping).toMatchObject({
message:
'IDB command interrupted: the backend was disposed during the call',
});
await disposing;
} finally {
Object.defineProperty(connectionState, 'alive', {
configurable: true,
writable: true,
value: true,
});
}
expect(disposing).toBeDefined();
expect(tapCalls()).toHaveLength(1);
});

it('keeps the connect error when cleanup after a failed connect also fails', async () => {
connectionMocks.connect.mockRejectedValueOnce(new Error('startup failed'));
connectionMocks.dispose.mockRejectedValueOnce(new Error('stop failed'));
await backend.typeText('abc');

await expect(backend.ensureConnected()).rejects.toThrow('startup failed');
expect(connectionMocks.dispose).toHaveBeenCalledTimes(1);
// Command+A failed undelivered, was resent once on a fresh companion,
// and the rest of the sequence ran.
expect(keyCalls('4')).toHaveLength(2);
expect(keyCalls('42')).toHaveLength(1);
expect(connectionMocks.connect).toHaveBeenCalledTimes(2);
});

it('reports a failed reconnect as IDB_COMPANION_UNAVAILABLE', async () => {
it('rejects typeText instead of appending when the clear cannot be delivered', async () => {
await backend.ensureConnected();
mockExecStrict.mockRejectedValue(new Error(hidInvalidated));
connectionMocks.connect.mockRejectedValueOnce(
new Error('Private IDB companion exited before readiness (1)'),
);

await expect(backend.tapCoordinates(1, 1)).rejects.toMatchObject({
code: 'IDB_COMPANION_UNAVAILABLE',
message: expect.stringContaining('exited before readiness'),
});
expect(tapCalls()).toHaveLength(1);
});

it('passes other failures through unchanged', async () => {
mockExecStrict.mockRejectedValue(new Error('No such app io.metamask'));

await expect(backend.tapCoordinates(1, 1)).rejects.toThrow(
'No such app io.metamask',
);
expect(connectionMocks.connect).toHaveBeenCalledTimes(1);
});

it('keeps non-strict commands non-throwing and retries them once', async () => {
mockExecStrict.mockResolvedValue('');
mockExec.mockImplementation(async (cmd, args) => {
if (cmd !== 'xcrun' && args?.[1] === 'key-sequence') {
return { exitCode: 1, stdout: '', stderr: hidInvalidated };
mockExecStrict.mockImplementation(async (_cmd, args) => {
if (args?.[1] === 'key' && args?.[2] === '42') {
throw new Error('StatusCode.UNAVAILABLE');
}
return { exitCode: 0, stdout: '', stderr: '' };
return '';
});

expect(await backend.typeText('abc')).toBeUndefined();
const keySequence = mockExec.mock.calls.filter(
([, args]) => args?.[1] === 'key-sequence',
);
expect(keySequence).toHaveLength(2);
});

it('returns the first non-strict failure when the reconnect fails', async () => {
await backend.ensureConnected();
mockExecStrict.mockResolvedValue('');
mockExec.mockImplementation(async (cmd, args) =>
cmd !== 'xcrun' && args?.[1] === 'key-sequence'
? { exitCode: 1, stdout: '', stderr: hidInvalidated }
: { exitCode: 0, stdout: '', stderr: '' },
);
connectionMocks.connect.mockRejectedValueOnce(new Error('no companion'));

// The failed key-sequence result is returned (not resent); the next
// command starts a fresh companion first, and the strict `ui text` succeeds.
expect(await backend.typeText('abc')).toBeUndefined();
await expect(backend.typeText('abc')).rejects.toMatchObject({
code: 'IDB_COMPANION_UNAVAILABLE',
message: expect.stringContaining('may have run'),
});
// Backspace is mutating with uncertain delivery: not resent, and the
// text is never typed onto the old value.
expect(keyCalls('42')).toHaveLength(1);
expect(
mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key-sequence'),
).toHaveLength(1);
expect(connectionMocks.connect).toHaveBeenCalledTimes(3);
mockExecStrict.mock.calls.filter(([, args]) => args?.[1] === 'text'),
).toHaveLength(0);
});

it('returns a non-strict failure without resending when delivery is uncertain', async () => {
mockExecStrict.mockResolvedValue('');
mockExec.mockImplementation(async (cmd, args) =>
cmd !== 'xcrun' && args?.[1] === 'key'
? { exitCode: 1, stdout: '', stderr: 'StatusCode.UNAVAILABLE' }
: { exitCode: 0, stdout: '', stderr: '' },
it('reports disposal during the typeText clear as an interruption', async () => {
await backend.ensureConnected();
let fail!: (error: Error) => void;
mockExecStrict.mockImplementation(async (_cmd, args) =>
args?.[1] === 'key'
? new Promise<string>((_resolve, reject) => {
fail = reject;
})
: '',
);
const typing = backend.typeText('abc').catch((e: unknown) => e);
await vi.waitFor(() => expect(fail).toBeDefined());

await backend.typeText('abc');
await backend.dispose();
fail(new Error(hidInvalidated));

expect(
mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key'),
).toHaveLength(1);
expect(await typing).toMatchObject({
message:
'IDB command interrupted: the backend was disposed during the call',
});
expect(keyCalls('4')).toHaveLength(1);
expect(connectionMocks.connect).toHaveBeenCalledTimes(1);
});

it('shares one reconnect between concurrent failing calls', async () => {
Expand Down
Loading
Loading