Skip to content
Open
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
233 changes: 133 additions & 100 deletions README.md

Large diffs are not rendered by default.

120 changes: 72 additions & 48 deletions SKILL.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions src/cli/mm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,14 +645,18 @@ export async function routeCommand(
const cdpMethod = args[0];
if (!cdpMethod) {
process.stderr.write(
'Usage: mm cdp <method> [params-json] [--timeout <ms>]\n' +
'Usage: mm cdp <method> [params-json] [--timeout <ms>] [--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',
' 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',
);
process.exit(1);
}
const cdpTimeout = parseIntFlag(args, '--timeout');
const cdpMetroPort = parseIntFlag(args, '--metro-port');
const cdpAppId = parseStringFlag(args, '--app-id');
const cdpParamsRaw =
args[1] !== undefined && !args[1].startsWith('--')
? args[1]
Expand Down Expand Up @@ -680,6 +684,21 @@ export async function routeCommand(
method: cdpMethod,
...(cdpParams ? { params: cdpParams } : {}),
...(cdpTimeout === undefined ? {} : { timeoutMs: cdpTimeout }),
...(cdpMetroPort === undefined ? {} : { metroPort: cdpMetroPort }),
...(cdpAppId ? { appId: cdpAppId } : {}),
});
break;
}
case 'hermes-targets': {
const targetsMetroPort = parseIntFlag(args, '--metro-port');
const targetsAppId = parseStringFlag(args, '--app-id');
const targetsAll = args.includes('--all');
await sendRequest(port, 'POST', '/tool/hermes_targets', {
...(targetsMetroPort === undefined
? {}
: { metroPort: targetsMetroPort }),
...(targetsAppId ? { appId: targetsAppId } : {}),
...(targetsAll ? { all: true } : {}),
});
break;
}
Expand Down Expand Up @@ -1436,7 +1455,8 @@ Advanced:
mm mock-network clear
mm mock-network list
mm mock-network requests [--limit <n>]
mm cdp <method> [params-json] [--timeout <ms>]
mm cdp <method> [params-json] [--timeout <ms>] [--metro-port <p>] [--app-id <id>]
mm hermes-targets [--all] [--metro-port <p>] [--app-id <id>] (mobile only)

Examples:
mm launch (from inside project)
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type {
GetTextActionResult,
PlatformScreenshotOptions,
} from './platform';
export { PlaywrightPlatformDriver } from './platform';
export { PlaywrightPlatformDriver, MobilePlatformDriver } from './platform';

// Session Manager Interface (transport-agnostic)
export type {
Expand Down
284 changes: 284 additions & 0 deletions src/platform/mobile-platform-driver.hermes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import type { DeviceBackend } from '@metamask/device-mcp';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const mocks = vi.hoisted(() => ({
runHermesCdp: vi.fn(),
fetchDiscoveryTargets: vi.fn(),
selectHermesTarget: vi.fn(),
hasAmbiguousTarget: vi.fn(),
resolve: vi.fn(),
getPinnedHermesDeviceId: vi.fn(),
setPinnedHermesDeviceId: vi.fn(),
HermesSession: vi.fn(),
}));

vi.mock('@metamask/device-mcp', () => ({
runHermesCdp: mocks.runHermesCdp,
fetchDiscoveryTargets: mocks.fetchDiscoveryTargets,
selectHermesTarget: mocks.selectHermesTarget,
hasAmbiguousTarget: mocks.hasAmbiguousTarget,
LEGACY_SYNTHETIC_TITLE: 'React Native Experimental (Improved Chrome Reloads)',
HermesSession: mocks.HermesSession,
}));

// 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;
}

describe('MobilePlatformDriver hermes delegation', () => {
beforeEach(() => {
mocks.HermesSession.mockImplementation(() => ({
resolve: mocks.resolve,
getPinnedHermesDeviceId: mocks.getPinnedHermesDeviceId,
setPinnedHermesDeviceId: mocks.setPinnedHermesDeviceId,
}));
mocks.resolve.mockReturnValue({
metroPort: 8081,
appId: 'io.metamask',
pinnedDeviceId: undefined,
});
mocks.getPinnedHermesDeviceId.mockReturnValue(undefined);
});

describe('session scoping', () => {
it('owns an instance-scoped Hermes session instead of the process singleton', () => {
const iosDriver = new MobilePlatformDriver(createBackend('ios'));
const androidDriver = new MobilePlatformDriver(createBackend('android'));

expect(iosDriver.getPlatform()).toBe('ios');
expect(androidDriver.getPlatform()).toBe('android');
expect(mocks.HermesSession).toHaveBeenCalledTimes(2);
expect(mocks.HermesSession).toHaveBeenNthCalledWith(1, {
platform: 'ios',
});
expect(mocks.HermesSession).toHaveBeenNthCalledWith(2, {
platform: 'android',
});
});
});

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

describe('cdp', () => {
it('resolves with the backend platform and delegates to runHermesCdp', async () => {
mocks.runHermesCdp.mockResolvedValue({ ok: true, result: { value: 2 } });
const driver = new MobilePlatformDriver(createBackend('android'));

const outcome = await driver.cdp({
method: 'Runtime.evaluate',
params: { expression: '1+1' },
timeoutMs: 30_000,
});

expect(mocks.resolve).toHaveBeenCalledWith({
metroPort: undefined,
appId: undefined,
platform: 'android',
});
expect(mocks.runHermesCdp).toHaveBeenCalledWith(
expect.objectContaining({
method: 'Runtime.evaluate',
params: { expression: '1+1' },
timeoutMs: 30_000,
metroPort: 8081,
appId: 'io.metamask',
pinnedDeviceId: undefined,
}),
);
expect(outcome).toStrictEqual({ ok: true, result: { value: 2 } });
});

it('persists the device pin via onPin when none is set', async () => {
mocks.runHermesCdp.mockImplementation(async (input) => {
input.onPin?.('device-42');
return { ok: true, result: null };
});
const driver = new MobilePlatformDriver(createBackend());

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

expect(mocks.setPinnedHermesDeviceId).toHaveBeenCalledWith('device-42');
});

it('does not overwrite an existing device pin', async () => {
mocks.getPinnedHermesDeviceId.mockReturnValue('device-existing');
mocks.runHermesCdp.mockImplementation(async (input) => {
input.onPin?.('device-new');
return { ok: true, result: null };
});
const driver = new MobilePlatformDriver(createBackend());

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

expect(mocks.setPinnedHermesDeviceId).not.toHaveBeenCalled();
});

it('maps the Hermes blocked-method code to MM_CDP_BLOCKED', async () => {
mocks.runHermesCdp.mockResolvedValue({
ok: false,
code: 'HERMES_BLOCKED_METHOD',
message:
'CDP method "Runtime.terminateExecution" is blocked for safety.',
});
const driver = new MobilePlatformDriver(createBackend());

const outcome = await driver.cdp({
method: 'Runtime.terminateExecution',
timeoutMs: 30_000,
});

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

it('maps other Hermes failures to MM_CDP_FAILED and preserves the HERMES_* code', async () => {
mocks.runHermesCdp.mockResolvedValue({
ok: false,
code: 'HERMES_TARGET_NOT_FOUND',
message: 'No Hermes debug target found for appId io.metamask',
});
const driver = new MobilePlatformDriver(createBackend());

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

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

describe('hermesTargets', () => {
it('reports the chosen target on successful selection', async () => {
const target = {
id: 'page-1',
title: 'MetaMask',
appId: 'io.metamask',
webSocketDebuggerUrl: 'ws://localhost:8081/x',
reactNative: {
logicalDeviceId: 'dev-1',
capabilities: { nativePageReloads: true },
},
};
mocks.fetchDiscoveryTargets.mockResolvedValue([target]);
mocks.selectHermesTarget.mockReturnValue({ ok: true, target });
const driver = new MobilePlatformDriver(createBackend());

const result = await driver.hermesTargets({});

expect(result.metroDown).toBe(false);
expect(result.targetsDiscovered).toBe(1);
expect(result.candidates).toStrictEqual([
{
id: 'page-1',
title: 'MetaMask',
appId: 'io.metamask',
logicalDeviceId: 'dev-1',
nativePageReloads: true,
webSocketDebuggerUrl: 'ws://localhost:8081/x',
},
]);
expect(result.chosen).toStrictEqual({
id: 'page-1',
logicalDeviceId: 'dev-1',
});
});

it('returns metroDown when discovery fails with a connection error', async () => {
mocks.fetchDiscoveryTargets.mockRejectedValue(
new Error('fetch failed: ECONNREFUSED'),
);
const driver = new MobilePlatformDriver(createBackend());

const result = await driver.hermesTargets({});

expect(result.metroDown).toBe(true);
expect(result.targetsDiscovered).toBe(0);
expect(result.candidates).toStrictEqual([]);
});

it('rethrows non-connection discovery errors', async () => {
mocks.fetchDiscoveryTargets.mockRejectedValue(new Error('boom'));
const driver = new MobilePlatformDriver(createBackend());

await expect(driver.hermesTargets({})).rejects.toThrowError('boom');
});

it('reports ambiguity when selection fails with HERMES_MULTIPLE_DEVICES', async () => {
const targets = [
{
appId: 'io.metamask',
webSocketDebuggerUrl: 'ws://localhost:8081/a',
reactNative: { logicalDeviceId: 'dev-1' },
},
{
appId: 'io.metamask',
webSocketDebuggerUrl: 'ws://localhost:8081/b',
reactNative: { logicalDeviceId: 'dev-2' },
},
];
mocks.fetchDiscoveryTargets.mockResolvedValue(targets);
mocks.selectHermesTarget.mockReturnValue({
ok: false,
code: 'HERMES_MULTIPLE_DEVICES',
message: 'Ambiguous Hermes target',
});
mocks.hasAmbiguousTarget.mockReturnValue(true);
const driver = new MobilePlatformDriver(createBackend());

const result = await driver.hermesTargets({});

expect(result.ambiguous).toBe('Ambiguous Hermes target');
expect(result.chosen).toBeUndefined();
});

it('reports noTargetReason for other selection failures', async () => {
mocks.fetchDiscoveryTargets.mockResolvedValue([]);
mocks.selectHermesTarget.mockReturnValue({
ok: false,
code: 'HERMES_TARGET_NOT_FOUND',
message: 'No Hermes debug target found',
});
mocks.hasAmbiguousTarget.mockReturnValue(false);
const driver = new MobilePlatformDriver(createBackend());

const result = await driver.hermesTargets({});

expect(result.noTargetReason).toStrictEqual({
code: 'HERMES_TARGET_NOT_FOUND',
message: 'No Hermes debug target found',
});
});

it('bypasses the appId filter when all is set', async () => {
const targets = [
{ appId: 'other.app', webSocketDebuggerUrl: 'ws://localhost:8081/a' },
];
mocks.fetchDiscoveryTargets.mockResolvedValue(targets);
mocks.selectHermesTarget.mockReturnValue({
ok: false,
code: 'HERMES_TARGET_NOT_FOUND',
message: 'No Hermes debug target found',
});
mocks.hasAmbiguousTarget.mockReturnValue(false);
const driver = new MobilePlatformDriver(createBackend());

const result = await driver.hermesTargets({ all: true });

expect(result.filterBypassed).toBe(true);
expect(result.candidates).toHaveLength(1);
});
});
});
Loading
Loading