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
32 changes: 19 additions & 13 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,36 +549,42 @@ mm mock-network requests [--limit <n>]
| ------------- | ------------------------------------------ |
| `--limit <n>` | Maximum number of recent records to return |

#### `mm cdp <method> [params-json] [--timeout <ms>] [--metro-port <p>] [--app-id <id>]`
#### `mm cdp <method> [params-json] [--timeout <ms>] [--target hermes|android-webview] [--url-filter <substr>] [--metro-port <p>] [--app-id <id>]`

Sends a raw Chrome DevTools Protocol command against the active session. This is an escape hatch for cases where structured tools are insufficient — e.g., evaluating JavaScript, enabling network tracking, or inspecting the DOM tree. It dispatches through the active platform driver, so it works on **both** browser and mobile sessions — but the target runtime and available methods differ (see the table below).
Sends a raw Chrome DevTools Protocol command against the active session. This is an escape hatch for cases where structured tools are insufficient — e.g., evaluating JavaScript, enabling network tracking, or inspecting the DOM tree. It dispatches through the active platform driver, so it works on **both** browser and mobile sessions — but the target runtime and available methods differ (see the table below). On mobile, `--target` selects between the React Native JS runtime (`hermes`, default) and a debuggable in-app Android WebView (`android-webview`).

```bash
# Browser
mm cdp Runtime.evaluate '{"expression":"document.title"}'
mm cdp Network.enable
mm cdp DOM.getDocument '{"depth":2}' --timeout 60000

# Mobile (Hermes) — evaluate JS in the running app
# Mobile (Hermes, default) — evaluate JS in the running React Native app
mm cdp Runtime.evaluate '{"expression":"1+1","returnByValue":true}' --app-id io.metamask --metro-port 8081

# Mobile (Android WebView) — drive the DOM of the in-app browser
mm cdp Runtime.evaluate '{"expression":"document.querySelector(\'#personalSign\').click()"}' --target android-webview
```

| Argument | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `<method>` | CDP method name (e.g., `Runtime.evaluate`, `DOM.getDocument` on browser; `Runtime.evaluate`, `Debugger.enable` on mobile) |
| `[params-json]` | Optional JSON object with method-specific parameters |
| `--timeout` | Per-command timeout in ms (default: 30 000, max: 30 000) |
| `--metro-port` | **Mobile only** — override the Metro inspector proxy port (default: 8081). Ignored on browser. |
| `--app-id` | **Mobile only** — override the expected app bundle identifier. Ignored on browser. |
| `--target` | **Mobile only** — `hermes` (default, RN JS runtime) or `android-webview` (in-app WebView DOM). Ignored on browser. |
| `--url-filter` | **`--target android-webview` only** — select the WebView page whose URL contains this substring. |
| `--metro-port` | **Mobile (Hermes) only** — override the Metro inspector proxy port (default: 8081). Ignored on browser. |
| `--app-id` | **Mobile (Hermes) only** — override the expected app bundle identifier. Ignored on browser. |

**Browser vs Mobile (Hermes):** the same command targets different runtimes.
**Three CDP targets:** the same command reaches different runtimes.

| Aspect | Browser (Playwright) | Mobile (React Native Hermes) |
| ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| Target | The page's Chrome DevTools session | The app's Hermes JS engine, via Metro's inspector proxy (needs a DEBUG build with Metro running) |
| Available domains | Full Chrome surface (`Runtime`, `DOM`, `Network`, `Page`, …) | JS-engine subset only (`Runtime`, `Debugger`, `Log`, `HeapProfiler`) — no `DOM`/`Page`/`Network` |
| Blocked methods | `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess` | `Runtime.terminateExecution`, `Inspector.detached` |
| Result shape | Standard CDP response | `Runtime.evaluate` nests the value at `result.result.value` |
| Aspect | Browser (Playwright) | Mobile — Hermes (`--target hermes`, default) | Mobile — Android WebView (`--target android-webview`) |
| ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| Target | The page's Chrome DevTools session | The app's Hermes JS engine, via Metro's inspector proxy (needs a DEBUG build with Metro running) | The web page inside a debuggable in-app Android WebView, via adb (needs `setWebContentsDebuggingEnabled(true)`) |
| Available domains | Full Chrome surface (`Runtime`, `DOM`, `Network`, `Page`, …) | JS-engine subset only (`Runtime`, `Debugger`, `Log`, `HeapProfiler`) — no `DOM`/`Page`/`Network` | Full Chrome surface (`Runtime`, `DOM`, `Network`, `Page`, `Input`) |
| Use it for | Web page DOM in the browser | The React Native app's own JavaScript | The web page DOM inside the app's in-app browser |
| Blocked methods | `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess` | `Runtime.terminateExecution`, `Inspector.detached` | `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess` |
| Result shape | Standard CDP response | `Runtime.evaluate` nests the value at `result.result.value` | `Runtime.evaluate` nests the value at `result.result.value` |

Blocked methods return `MM_CDP_BLOCKED` on either platform; other failures return `MM_CDP_FAILED` (on mobile the underlying `HERMES_*` code is preserved in the message). The tool is categorized as **mutating** — run `describe-screen` afterward to re-sync if the call changed runtime/page state.

Expand Down Expand Up @@ -635,7 +641,7 @@ Presses a hardware/system button. Only `home`, `back`, `enter`, and `lock` are a

#### `mm device-context list` / `mm device-context switch <name>`

Lists available native/webview contexts, or switches the active context (e.g. `WEBVIEW_1`).
Lists available native/webview contexts, or switches the active context. On **iOS** WebView contexts look like `WEBVIEW_1` (Appium backend). On **Android** a single `WEBVIEW` context appears whenever a debuggable in-app WebView is open; to drive that page's DOM use `mm cdp --target android-webview` (see [`mm cdp`](#mm-cdp-method-params-json---timeout-ms---target-hermesandroid-webview---url-filter-substr---metro-port-p---app-id-id)).

#### `mm device-clipboard read` / `mm device-clipboard write <text>`

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"hono": "^4.12.25"
},
"dependencies": {
"@metamask/device-mcp": "^0.4.1",
"@metamask/device-mcp": "^0.4.2",
"cosmiconfig": "^9.0.0",
"express": "^5.2.1",
"zod": "^4.4.3"
Expand Down
48 changes: 48 additions & 0 deletions src/cli/mm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,54 @@ describe('routeCommand', () => {
);
});

it('routes cdp with --target android-webview and --url-filter', async () => {
await routeCommand(
'cdp',
[
'Runtime.evaluate',
'{"expression":"1+1"}',
'--target',
'android-webview',
'--url-filter',
'test-dapp',
],
3000,
);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/cdp',
expect.objectContaining({
body: JSON.stringify({
method: 'Runtime.evaluate',
params: { expression: '1+1' },
target: 'android-webview',
urlFilter: 'test-dapp',
}),
}),
);
});

it('routes cdp with --target hermes', async () => {
await routeCommand('cdp', ['Runtime.evaluate', '--target', 'hermes'], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/cdp',
expect.objectContaining({
body: JSON.stringify({
method: 'Runtime.evaluate',
target: 'hermes',
}),
}),
);
});

it('exits when cdp --target is invalid', async () => {
await expect(
routeCommand('cdp', ['Runtime.evaluate', '--target', 'bogus'], 3000),
).rejects.toThrowError('process.exit');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('--target must be'),
);
});

it('routes scroll-to-element with a11y ref', async () => {
await routeCommand('scroll-to-element', ['e1'], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
Expand Down
22 changes: 19 additions & 3 deletions src/cli/mm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,18 +653,32 @@ export async function routeCommand(
const cdpMethod = args[0];
if (!cdpMethod) {
process.stderr.write(
'Usage: mm cdp <method> [params-json] [--timeout <ms>] [--metro-port <port>] [--app-id <id>]\n' +
'Usage: mm cdp <method> [params-json] [--timeout <ms>] [--target hermes|android-webview] [--url-filter <substr>] [--metro-port <port>] [--app-id <id>]\n' +
' mm cdp Runtime.evaluate \'{"expression":"document.title"}\'\n' +
' mm cdp Network.enable\n' +
' mm cdp DOM.getDocument \'{"depth":2}\' --timeout 60000\n' +
' # 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',
' 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',
);
process.exit(1);
}
const cdpTimeout = parseIntFlag(args, '--timeout');
const cdpMetroPort = parseIntFlag(args, '--metro-port');
const cdpAppId = parseStringFlag(args, '--app-id');
const cdpTarget = parseStringFlag(args, '--target');
const cdpUrlFilter = parseStringFlag(args, '--url-filter');
if (
cdpTarget !== undefined &&
cdpTarget !== 'hermes' &&
cdpTarget !== 'android-webview'
) {
process.stderr.write(
'Error: --target must be "hermes" or "android-webview"\n',
);
process.exit(1);
}
const cdpParamsRaw =
args[1] !== undefined && !args[1].startsWith('--')
? args[1]
Expand Down Expand Up @@ -694,6 +708,8 @@ export async function routeCommand(
...(cdpTimeout === undefined ? {} : { timeoutMs: cdpTimeout }),
...(cdpMetroPort === undefined ? {} : { metroPort: cdpMetroPort }),
...(cdpAppId ? { appId: cdpAppId } : {}),
...(cdpTarget ? { target: cdpTarget } : {}),
...(cdpUrlFilter ? { urlFilter: cdpUrlFilter } : {}),
});
break;
}
Expand Down Expand Up @@ -1677,7 +1693,7 @@ Advanced:
mm mock-network clear
mm mock-network list
mm mock-network requests [--limit <n>]
mm cdp <method> [params-json] [--timeout <ms>] [--metro-port <p>] [--app-id <id>]
mm cdp <method> [params-json] [--timeout <ms>] [--target hermes|android-webview] [--url-filter <substr>] [--metro-port <p>] [--app-id <id>]
mm hermes-targets [--all] [--metro-port <p>] [--app-id <id>] (mobile only)

Mobile (iOS/Android only):
Expand Down
128 changes: 126 additions & 2 deletions src/platform/mobile-platform-driver.hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ vi.mock('@metamask/device-mcp', () => ({
// Imported AFTER vi.mock so the driver binds to the mocked device-mcp runtime.
const { MobilePlatformDriver } = await import('./mobile-platform-driver.js');

function createBackend(platform: 'ios' | 'android' = 'android'): DeviceBackend {
return { platform } as unknown as DeviceBackend;
function createBackend(
platform: 'ios' | 'android' = 'android',
overrides: Partial<DeviceBackend> = {},
): DeviceBackend {
return { platform, ...overrides } as unknown as DeviceBackend;
}

describe('MobilePlatformDriver hermes delegation', () => {
Expand Down Expand Up @@ -336,3 +339,124 @@ describe('MobilePlatformDriver hermes delegation', () => {
});
});
});

describe('MobilePlatformDriver webview cdp routing', () => {
beforeEach(() => {
mocks.HermesSession.mockImplementation(() => ({
resolve: mocks.resolve,
getPinnedHermesDeviceId: mocks.getPinnedHermesDeviceId,
setPinnedHermesDeviceId: mocks.setPinnedHermesDeviceId,
}));
});

afterEach(() => {
vi.clearAllMocks();
});

it('routes target "android-webview" to backend.webviewCdp and NOT Hermes', async () => {
const webviewCdp = vi
.fn()
.mockResolvedValue({ ok: true, result: { value: 'clicked' } });
const driver = new MobilePlatformDriver(
createBackend('android', { webviewCdp }),
);

const outcome = await driver.cdp({
method: 'Runtime.evaluate',
params: { expression: "document.querySelector('#x').click()" },
timeoutMs: 30_000,
target: 'android-webview',
urlFilter: 'test-dapp',
});

expect(outcome).toStrictEqual({ ok: true, result: { value: 'clicked' } });
expect(webviewCdp).toHaveBeenCalledWith({
method: 'Runtime.evaluate',
params: { expression: "document.querySelector('#x').click()" },
timeoutMs: 30_000,
urlFilter: 'test-dapp',
});
expect(mocks.runHermesCdp).not.toHaveBeenCalled();
});

it('defaults to the Hermes path when target is omitted', async () => {
mocks.resolve.mockReturnValue({
metroPort: 8081,
appId: 'io.metamask',
pinnedDeviceId: undefined,
});
mocks.getPinnedHermesDeviceId.mockReturnValue(undefined);
mocks.runHermesCdp.mockResolvedValue({ ok: true, result: { value: 2 } });
const webviewCdp = vi.fn();
const driver = new MobilePlatformDriver(
createBackend('android', { webviewCdp }),
);

await driver.cdp({ method: 'Runtime.evaluate', timeoutMs: 30_000 });

expect(mocks.runHermesCdp).toHaveBeenCalledTimes(1);
expect(webviewCdp).not.toHaveBeenCalled();
});

it('maps the WebView blocked-method code to MM_CDP_BLOCKED', async () => {
const webviewCdp = vi.fn().mockResolvedValue({
ok: false,
code: 'WEBVIEW_BLOCKED_METHOD',
message: 'blocked',
});
const driver = new MobilePlatformDriver(
createBackend('android', { webviewCdp }),
);

const outcome = await driver.cdp({
method: 'Browser.close',
timeoutMs: 30_000,
target: 'android-webview',
});

expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.code).toBe('MM_CDP_BLOCKED');
expect(outcome.message).toContain('WEBVIEW_BLOCKED_METHOD');
}
});

it('maps other WebView failures to MM_CDP_FAILED', async () => {
const webviewCdp = vi.fn().mockResolvedValue({
ok: false,
code: 'WEBVIEW_TARGET_NOT_FOUND',
message: 'no page',
});
const driver = new MobilePlatformDriver(
createBackend('android', { webviewCdp }),
);

const outcome = await driver.cdp({
method: 'Runtime.evaluate',
timeoutMs: 30_000,
target: 'android-webview',
});

expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.code).toBe('MM_CDP_FAILED');
expect(outcome.message).toContain('WEBVIEW_TARGET_NOT_FOUND');
}
});

it('returns MM_CDP_FAILED when the backend lacks webviewCdp support', async () => {
const driver = new MobilePlatformDriver(createBackend('android'));

const outcome = await driver.cdp({
method: 'Runtime.evaluate',
timeoutMs: 30_000,
target: 'android-webview',
});

expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.code).toBe('MM_CDP_FAILED');
expect(outcome.message).toContain('WebView CDP is not available');
}
});
});
Loading
Loading