From 49df4fdb9a13f0a4a6b27b9f875498b4b38fe44e Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Fri, 5 Jun 2026 18:40:54 +0200 Subject: [PATCH 1/9] chore: add mobile platform driver --- package.json | 3 +- src/platform/index.ts | 1 + src/platform/mobile-platform-driver.test.ts | 451 ++++++++++++++++++++ src/platform/mobile-platform-driver.ts | 355 +++++++++++++++ vitest.config.mts | 2 +- yarn.lock | 201 ++++++++- 6 files changed, 1002 insertions(+), 11 deletions(-) create mode 100644 src/platform/mobile-platform-driver.test.ts create mode 100644 src/platform/mobile-platform-driver.ts diff --git a/package.json b/package.json index 0a45d3d..90cc46b 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,10 @@ "fflate": "0.8.2" }, "dependencies": { + "@metamask/device-mcp": "^0.2.0", "cosmiconfig": "^9.0.0", "express": "^5.2.1", - "zod": "^4.3.5" + "zod": "^4.4.3" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", diff --git a/src/platform/index.ts b/src/platform/index.ts index 0a54f09..6ffb045 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -10,3 +10,4 @@ export type { } from './types.js'; export { PlaywrightPlatformDriver } from './playwright-driver.js'; +export { MobilePlatformDriver } from './mobile-platform-driver.js'; diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts new file mode 100644 index 0000000..6bd5d7e --- /dev/null +++ b/src/platform/mobile-platform-driver.test.ts @@ -0,0 +1,451 @@ +import type { DeviceBackend, UIElement } from '@metamask/device-mcp'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { MobilePlatformDriver } from './mobile-platform-driver.js'; + +function makeElement(overrides: Partial = {}): UIElement { + return { + type: 'Button', + frame: { x: 0, y: 0, width: 100, height: 44 }, + enabled: true, + ...overrides, + }; +} + +function createMockBackend( + overrides: Partial = {}, +): DeviceBackend { + return { + platform: 'ios', + getDeviceInfo: vi.fn(), + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [], + raw: '[]', + timestamp: Date.now(), + }), + tapElement: vi.fn().mockResolvedValue({ + success: true, + x: 50, + y: 50, + targetDescription: 'Button', + }), + tapCoordinates: vi.fn(), + typeText: vi.fn(), + swipe: vi.fn(), + waitForElement: vi.fn().mockResolvedValue(makeElement()), + getAppState: vi.fn().mockResolvedValue({ + bundleId: 'io.metamask', + state: 'Running', + }), + screenshot: vi.fn().mockResolvedValue({ + data: 'base64data', + format: 'png' as const, + path: '/tmp/screenshot.png', + }), + openApp: vi.fn(), + closeApp: vi.fn(), + pressButton: vi.fn(), + dismissKeyboard: vi.fn(), + dismissAlert: vi.fn(), + getLogs: vi.fn(), + longPress: vi.fn(), + scrollToElement: vi.fn(), + getAlertText: vi.fn(), + getWindowSize: vi.fn(), + getContexts: vi.fn(), + setContext: vi.fn(), + getClipboard: vi.fn(), + setClipboard: vi.fn(), + startScreenRecording: vi.fn(), + stopScreenRecording: vi.fn(), + getElementText: vi.fn().mockResolvedValue('Hello World'), + ...overrides, + }; +} + +describe('MobilePlatformDriver', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getPlatform', () => { + it('returns ios for ios backend', () => { + const driver = new MobilePlatformDriver(createMockBackend()); + expect(driver.getPlatform()).toBe('ios'); + }); + + it('returns android for android backend', () => { + const backend = createMockBackend({ platform: 'android' }); + const driver = new MobilePlatformDriver(backend); + expect(driver.getPlatform()).toBe('android'); + }); + }); + + describe('getCurrentUrl', () => { + it('returns empty string', () => { + const driver = new MobilePlatformDriver(createMockBackend()); + expect(driver.getCurrentUrl()).toBe(''); + }); + }); + + describe('click', () => { + it('waits for element then taps', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'identifier:submit-btn']]); + + const result = await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.waitForElement).toHaveBeenCalledWith( + { identifier: 'submit-btn' }, + 5000, + ); + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'submit-btn', + }); + expect(result).toStrictEqual({ + clicked: true, + target: 'a11yRef:e1', + }); + }); + + it('resolves testId target', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.click('testId', 'confirm-button', new Map(), 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'confirm-button', + }); + }); + + it('resolves selector as identifier', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.click('selector', 'my-element', new Map(), 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'my-element', + }); + }); + + it('throws for unknown a11yRef', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.click('a11yRef', 'e99', new Map(), 5000), + ).rejects.toThrowError('Unknown a11yRef: e99'); + }); + }); + + describe('type', () => { + it('waits, taps to focus, then types', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + const result = await driver.type( + 'testId', + 'password-input', + 'secret123', + new Map(), + 5000, + ); + + expect(backend.waitForElement).toHaveBeenCalledWith( + { identifier: 'password-input' }, + 5000, + ); + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'password-input', + }); + expect(backend.typeText).toHaveBeenCalledWith('secret123'); + expect(result).toStrictEqual({ + typed: true, + target: 'testId:password-input', + textLength: 9, + }); + }); + }); + + describe('waitForElement', () => { + it('delegates to backend.waitForElement', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e5', 'label:Settings']]); + + await driver.waitForElement('a11yRef', 'e5', refMap, 10000); + + expect(backend.waitForElement).toHaveBeenCalledWith( + { label: 'Settings' }, + 10000, + ); + }); + }); + + describe('getText', () => { + it('waits then reads element text', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e3', 'identifier:balance-label']]); + + const result = await driver.getText('a11yRef', 'e3', refMap, 5000); + + expect(backend.waitForElement).toHaveBeenCalledWith( + { identifier: 'balance-label' }, + 5000, + ); + expect(backend.getElementText).toHaveBeenCalledWith({ + identifier: 'balance-label', + }); + expect(result).toStrictEqual({ + text: 'Hello World', + target: 'a11yRef:e3', + length: 11, + }); + }); + + it('resolves value: prefix to text query', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e2', 'value:0.5 ETH']]); + + await driver.getText('a11yRef', 'e2', refMap, 5000); + + expect(backend.getElementText).toHaveBeenCalledWith({ + text: '0.5 ETH', + }); + }); + }); + + describe('getAccessibilityTree', () => { + it('normalizes UIElement hierarchy to A11yNodeTrimmed', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ + type: 'Button', + label: 'Submit', + identifier: 'submit-btn', + }), + makeElement({ + type: 'TextField', + label: 'Email', + value: 'user@test.com', + enabled: false, + }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes, refMap } = await driver.getAccessibilityTree(); + + expect(nodes).toHaveLength(2); + expect(nodes[0]).toStrictEqual({ + ref: 'e1', + role: 'Button', + name: 'Submit', + path: [], + testId: 'submit-btn', + }); + expect(nodes[1]).toStrictEqual({ + ref: 'e2', + role: 'TextField', + name: 'Email', + path: [], + disabled: true, + textContent: 'user@test.com', + }); + expect(refMap.get('e1')).toBe('identifier:submit-btn'); + expect(refMap.get('e2')).toBe('label:Email'); + }); + + it('assigns sequential refs to nested children', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ + type: 'Window', + label: 'Main', + children: [ + makeElement({ type: 'Button', label: 'OK' }), + makeElement({ type: 'Button', label: 'Cancel' }), + ], + }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes } = await driver.getAccessibilityTree(); + + expect(nodes).toHaveLength(3); + expect(nodes[0].ref).toBe('e1'); + expect(nodes[0].path).toStrictEqual([]); + expect(nodes[1].ref).toBe('e2'); + expect(nodes[1].path).toStrictEqual(['Window']); + expect(nodes[2].ref).toBe('e3'); + expect(nodes[2].path).toStrictEqual(['Window']); + }); + + it('builds refMap with value fallback', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [makeElement({ type: 'StaticText', value: '0.5 ETH' })], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { refMap } = await driver.getAccessibilityTree(); + + expect(refMap.get('e1')).toBe('value:0.5 ETH'); + }); + }); + + describe('getTestIds', () => { + it('collects identifiers from hierarchy', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ + type: 'Button', + identifier: 'submit-btn', + label: 'Submit', + }), + makeElement({ type: 'StaticText', label: 'Hello' }), + makeElement({ + type: 'TextField', + identifier: 'email-input', + label: 'Email', + }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const items = await driver.getTestIds(); + + expect(items).toHaveLength(2); + expect(items[0]).toStrictEqual({ + testId: 'submit-btn', + tag: 'Button', + text: 'Submit', + visible: true, + }); + expect(items[1]).toStrictEqual({ + testId: 'email-input', + tag: 'TextField', + text: 'Email', + visible: true, + }); + }); + + it('respects limit', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ identifier: 'a' }), + makeElement({ identifier: 'b' }), + makeElement({ identifier: 'c' }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const items = await driver.getTestIds(2); + + expect(items).toHaveLength(2); + }); + }); + + describe('screenshot', () => { + it('returns mapped result with empty base64 and zero dimensions', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + const result = await driver.screenshot({ name: 'test-shot' }); + + expect(result).toStrictEqual({ + path: '/tmp/screenshot.png', + base64: '', + width: 0, + height: 0, + }); + }); + + it('uses name as fallback path when backend returns no path', async () => { + const backend = createMockBackend({ + screenshot: vi.fn().mockResolvedValue({ + data: 'base64data', + format: 'png', + }), + }); + const driver = new MobilePlatformDriver(backend); + + const result = await driver.screenshot({ name: 'my-screen' }); + + expect(result.path).toBe('my-screen.png'); + }); + }); + + describe('getAppState', () => { + it('returns ExtensionState with mobile defaults for running app', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + const state = await driver.getAppState(); + + expect(state).toStrictEqual({ + isLoaded: true, + currentUrl: '', + extensionId: 'io.metamask', + isUnlocked: true, + currentScreen: 'unknown', + accountAddress: null, + networkName: null, + chainId: null, + balance: null, + }); + }); + + it('returns isLoaded false for non-running app', async () => { + const backend = createMockBackend({ + getAppState: vi.fn().mockResolvedValue({ + bundleId: 'io.metamask', + state: 'NotRunning', + }), + }); + const driver = new MobilePlatformDriver(backend); + + const state = await driver.getAppState(); + + expect(state.isLoaded).toBe(false); + expect(state.isUnlocked).toBe(false); + }); + + it('uses custom bundleId', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend, 'com.example.app'); + + await driver.getAppState(); + + expect(backend.getAppState).toHaveBeenCalledWith('com.example.app'); + }); + }); +}); diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts new file mode 100644 index 0000000..cd6d321 --- /dev/null +++ b/src/platform/mobile-platform-driver.ts @@ -0,0 +1,355 @@ +import type { + DeviceBackend, + ElementQuery, + UIElement, +} from '@metamask/device-mcp'; + +import type { + IPlatformDriver, + TargetType, + ClickActionResult, + TypeActionResult, + GetTextActionResult, + PlatformScreenshotOptions, + PlatformType, + WithinScope, +} from './types.js'; +import type { + ScreenshotResult, + ExtensionState, +} from '../capabilities/types.js'; +import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; + +const DEFAULT_BUNDLE_ID = 'io.metamask'; +const DEFAULT_TESTID_LIMIT = 150; + +/** + * Platform driver for mobile devices backed by @metamask/device-mcp. + * Wraps DeviceBackend behind the IPlatformDriver interface so the + * tool layer can interact with iOS/Android devices using the same + * API it uses for Playwright browser sessions. + */ +export class MobilePlatformDriver implements IPlatformDriver { + readonly #backend: DeviceBackend; + + readonly #bundleId: string; + + /** + * @param backend - The device-mcp backend to delegate to. + * @param bundleId - The app bundle ID for getAppState calls. + */ + constructor(backend: DeviceBackend, bundleId: string = DEFAULT_BUNDLE_ID) { + this.#backend = backend; + this.#bundleId = bundleId; + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param refMap - Map of a11y refs to stable identifiers. + * @param timeoutMs - Maximum wait time in milliseconds. + * @param _within - Unused on mobile — elements are always searched globally. + * @returns Click result with success status and target info. + */ + async click( + targetType: TargetType, + targetValue: string, + refMap: Map, + timeoutMs: number, + _within?: WithinScope, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.waitForElement(query, timeoutMs); + await this.#backend.tapElement(query); + return { clicked: true, target: `${targetType}:${targetValue}` }; + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param text - Text to type into the element. + * @param refMap - Map of a11y refs to stable identifiers. + * @param timeoutMs - Maximum wait time in milliseconds. + * @param _within - Unused on mobile. + * @returns Type result with success status and text length. + */ + async type( + targetType: TargetType, + targetValue: string, + text: string, + refMap: Map, + timeoutMs: number, + _within?: WithinScope, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.waitForElement(query, timeoutMs); + await this.#backend.tapElement(query); + await this.#backend.typeText(text); + return { + typed: true, + target: `${targetType}:${targetValue}`, + textLength: text.length, + }; + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param refMap - Map of a11y refs to stable identifiers. + * @param timeoutMs - Maximum wait time in milliseconds. + * @param _within - Unused on mobile. + */ + async waitForElement( + targetType: TargetType, + targetValue: string, + refMap: Map, + timeoutMs: number, + _within?: WithinScope, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.waitForElement(query, timeoutMs); + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param refMap - Map of a11y refs to stable identifiers. + * @param timeoutMs - Maximum wait time in milliseconds. + * @param _within - Unused on mobile. + * @returns The element's text content, target descriptor, and length. + */ + async getText( + targetType: TargetType, + targetValue: string, + refMap: Map, + timeoutMs: number, + _within?: WithinScope, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.waitForElement(query, timeoutMs); + const text = await this.#backend.getElementText(query); + return { + text, + target: `${targetType}:${targetValue}`, + length: text.length, + }; + } + + /** + * @param _rootSelector - Unused on mobile — no CSS selectors. + * @returns Accessibility nodes and ref-to-identifier map. + */ + async getAccessibilityTree( + _rootSelector?: string, + ): Promise<{ nodes: A11yNodeTrimmed[]; refMap: Map }> { + const snapshot = await this.#backend.snapshot(); + return normalizeSnapshot(snapshot.hierarchy); + } + + /** + * @param limit - Maximum number of test IDs to return. + * @returns Array of test ID items with identifiers from the UI hierarchy. + */ + async getTestIds(limit?: number): Promise { + const snapshot = await this.#backend.snapshot(); + const items: TestIdItem[] = []; + const max = limit ?? DEFAULT_TESTID_LIMIT; + collectTestIds(snapshot.hierarchy, items, max); + return items; + } + + /** + * @param options - Screenshot options. + * @returns Screenshot result with path and placeholder dimensions. + */ + async screenshot( + options: PlatformScreenshotOptions, + ): Promise { + const result = await this.#backend.screenshot(); + return { + path: result.path ?? `${options.name}.${result.format}`, + base64: '', + width: 0, + height: 0, + }; + } + + /** + * @returns Extension state with mobile-appropriate defaults. + */ + async getAppState(): Promise { + const appState = await this.#backend.getAppState(this.#bundleId); + const isRunning = appState.state === 'Running' || appState.state === '4'; + return { + isLoaded: isRunning, + currentUrl: '', + extensionId: this.#bundleId, + isUnlocked: isRunning, + currentScreen: 'unknown', + accountAddress: null, + networkName: null, + chainId: null, + balance: null, + }; + } + + /** + * @returns Empty string — no URL concept on mobile. + */ + getCurrentUrl(): string { + return ''; + } + + /** + * @returns The device platform as a PlatformType. + */ + getPlatform(): PlatformType { + return this.#backend.platform; + } +} + +/** + * Resolves a target selector to a DeviceBackend ElementQuery. + * + * @param targetType - The selector type (a11yRef, testId, selector). + * @param targetValue - The selector value. + * @param refMap - The ref-to-stable-identifier map from getAccessibilityTree. + * @returns An ElementQuery suitable for DeviceBackend methods. + */ +function resolveTargetToQuery( + targetType: TargetType, + targetValue: string, + refMap: Map, +): ElementQuery { + switch (targetType) { + case 'a11yRef': { + const resolution = refMap.get(targetValue); + if (!resolution) { + throw new Error( + `Unknown a11yRef: ${targetValue}. ` + + `Available refs: ${Array.from(refMap.keys()).join(', ')}`, + ); + } + return parseStableIdentifier(resolution); + } + case 'testId': + return { identifier: targetValue }; + case 'selector': + return { identifier: targetValue }; + default: { + const _exhaustive: never = targetType; + throw new Error(`Unknown target type: ${_exhaustive as string}`); + } + } +} + +/** + * Parses a stable identifier string into an ElementQuery. + * + * @param stableId - String in "identifier:xxx", "label:xxx", or "value:xxx" format. + * @returns The corresponding ElementQuery. + */ +function parseStableIdentifier(stableId: string): ElementQuery { + const colonIndex = stableId.indexOf(':'); + if (colonIndex < 0) { + return { identifier: stableId }; + } + const prefix = stableId.slice(0, colonIndex); + const value = stableId.slice(colonIndex + 1); + + switch (prefix) { + case 'identifier': + return { identifier: value }; + case 'label': + return { label: value }; + case 'value': + return { text: value }; + default: + return { identifier: stableId }; + } +} + +/** + * Normalizes a UIElement hierarchy into A11yNodeTrimmed nodes with sequential refs. + * + * @param hierarchy - The raw UIElement tree from a device snapshot. + * @returns Trimmed nodes and the ref-to-stable-identifier map. + */ +function normalizeSnapshot(hierarchy: UIElement[]): { + nodes: A11yNodeTrimmed[]; + refMap: Map; +} { + const nodes: A11yNodeTrimmed[] = []; + const refMap = new Map(); + let refCounter = 0; + + /** + * @param elements - UIElement nodes to walk. + * @param path - Accumulated ancestor roles. + */ + function walk(elements: UIElement[], path: string[]): void { + for (const el of elements) { + refCounter += 1; + const ref = `e${refCounter}`; + const role = el.type || 'element'; + const name = el.label ?? el.value ?? ''; + + const node: A11yNodeTrimmed = { ref, role, name, path }; + if (!el.enabled) { + node.disabled = true; + } + if (el.identifier) { + node.testId = el.identifier; + } + if (el.value && el.value !== name) { + node.textContent = el.value; + } + nodes.push(node); + + if (el.identifier) { + refMap.set(ref, `identifier:${el.identifier}`); + } else if (el.label) { + refMap.set(ref, `label:${el.label}`); + } else if (el.value) { + refMap.set(ref, `value:${el.value}`); + } + + if (el.children?.length) { + walk(el.children, [...path, role]); + } + } + } + + walk(hierarchy, []); + return { nodes, refMap }; +} + +/** + * Recursively collects test IDs from a UIElement hierarchy. + * + * @param elements - The UIElement nodes to scan. + * @param items - Accumulator for discovered test ID items. + * @param max - Maximum number of items to collect. + */ +function collectTestIds( + elements: UIElement[], + items: TestIdItem[], + max: number, +): void { + for (const el of elements) { + if (items.length >= max) { + return; + } + if (el.identifier) { + items.push({ + testId: el.identifier, + tag: el.type || 'element', + text: el.label ?? el.value, + visible: true, + }); + } + if (el.children?.length) { + collectTestIds(el.children, items, max); + } + } +} diff --git a/vitest.config.mts b/vitest.config.mts index 0cf43b2..077a3fc 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -36,7 +36,7 @@ export default defineConfig({ // Disabled in CI to prevent non-deterministic config changes. autoUpdate: !process.env.CI, branches: 89.52, - functions: 92.3, + functions: 92.62, lines: 95.5, statements: 95.21, }, diff --git a/yarn.lock b/yarn.lock index a00c793..6ffbc86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -727,6 +727,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: 10/618dd95feeb3fd11ec8502e088879cd86529523788de19602edebd16892dd61899e73564d6e3d00875cc5a49488a908ddb2aa425d28f9cdeb7f22cfecabf022c + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -917,6 +926,7 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^5.3.0" + "@metamask/device-mcp": "npm:^0.2.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" @@ -949,7 +959,7 @@ __metadata: typescript-eslint: "npm:^8.48.1" vite: "npm:^6.4.1" vitest: "npm:^3.0.7" - zod: "npm:^4.3.5" + zod: "npm:^4.4.3" peerDependencies: "@playwright/test": ^1.49.0 playwright: ^1.49.0 @@ -958,6 +968,18 @@ __metadata: languageName: unknown linkType: soft +"@metamask/device-mcp@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/device-mcp@npm:0.2.0" + dependencies: + "@modelcontextprotocol/sdk": "npm:^1.12.1" + zod: "npm:^4.4.3" + bin: + device-mcp: ./dist/cli/device-mcp.mjs + checksum: 10/c7e5deb2632126ce5183971841b10a3f772e2878e553f9e3beb450dd9ff1cf036097d12ef6738e95bd6cc1166135996df047eb0cc2824bfab70c57f46b7d82c9 + languageName: node + linkType: hard + "@metamask/eslint-config-nodejs@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/eslint-config-nodejs@npm:15.0.0" @@ -1021,6 +1043,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.12.1": + version: 1.29.0 + resolution: "@modelcontextprotocol/sdk@npm:1.29.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10/ff551b97e06b661f95fec8fd34e112c446e69894a84a9979cdac369fb5de27f0a1a5c1f4e2a1f270cc60f93e54c28a8059a94ca51c3d528d2670ade874b244f9 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -2271,6 +2326,20 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:^3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" + dependencies: + ajv: "npm:^8.0.0" + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 10/5679b9f9ced9d0213a202a37f3aa91efcffe59a6de1a6e3da5c873344d3c161820a1f11cc29899661fee36271fd2895dd3851b6461c902a752ad661d1c1e8722 + languageName: node + linkType: hard + "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -2283,6 +2352,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.0.0, ajv@npm:^8.17.1": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/5ce59c0537f4c2aca9a758b412659ec70acb4d5dde971c10ecf21d2e3d799f99acdb4a08e1f5fb2e067c8542930398aae793bb996bb07d3feb81dae22fe2ada9 + languageName: node + linkType: hard + "ansi-escapes@npm:^7.0.0": version: 7.2.0 resolution: "ansi-escapes@npm:7.2.0" @@ -2800,6 +2881,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: "npm:^4" + vary: "npm:^1" + checksum: 10/aa7174305b21ceb90f9c84f4eaa32f04432d333addbfdc0d1eb7310393c48902e5364aada5ac2f5d054528d63b3179238444475426fcb74e1e345077de485727 + languageName: node + linkType: hard + "cosmiconfig@npm:^7.1.0": version: 7.1.0 resolution: "cosmiconfig@npm:7.1.0" @@ -2837,7 +2928,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -3657,6 +3748,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 10/6aa03b4d6e3450935690fd9cca6e47b9877287c9419dba9705b85a73e741c7dfbc22b4ebeca25adf05c9549e33c4491e3ca71f80ddfac585e469b7da91f76e20 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10/e034915bc97068d1d38617951afd798e6776d6a3a78e36a7569c235b177c7afc2625c9fe82656f7341ab72c7eeecb3fd507b7f88e9328f2448872ff9c4742bb6 + languageName: node + linkType: hard + "execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -3697,6 +3804,17 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.5.2 + resolution: "express-rate-limit@npm:8.5.2" + dependencies: + ip-address: "npm:^10.2.0" + peerDependencies: + express: ">= 4.11" + checksum: 10/de2aa4582d3b1b1d242fdb8dba7b3b1c64e3a58dfa7a40d370681c175a61321fb550c6b190d755f47cefdf28f35eaad6c99ea196f99caec8caaffc2462fea1ae + languageName: node + linkType: hard + "express@npm:^5.2.1": version: 5.2.1 resolution: "express@npm:5.2.1" @@ -3761,6 +3879,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.2 + resolution: "fast-uri@npm:3.1.2" + checksum: 10/1dff04865b2a38d3e0659deadfbf72efdf83a776bfbf9667e4aa9e5a3ec31bc341cda9622136b32b7652a857c8ba11896794186e8f876f8b2b72731fce8622f6 + languageName: node + linkType: hard + "fdir@npm:^6.4.4, fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" @@ -4166,6 +4291,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.23 + resolution: "hono@npm:4.12.23" + checksum: 10/721a7f0728fafe229ea84d5f0f3a2575b44dce5eb2047e49f4e7929257bb59511c80adefbe09637480ca3b65ad553510d7cd75c7dc696a916f141aefcbcb2667 + languageName: node + linkType: hard + "hosted-git-info@npm:^9.0.0": version: 9.0.2 resolution: "hosted-git-info@npm:9.0.2" @@ -4313,6 +4445,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.2.0": + version: 10.2.0 + resolution: "ip-address@npm:10.2.0" + checksum: 10/12fec399e1af5753ac322e47a6d81a50d3a528b3abb17c09525b2a2edcaedcca628c40520706f7037bc4d8e951b0296c47e7b86d0a8e6e2335c8f0ba4afcfac1 + languageName: node + linkType: hard + "ipaddr.js@npm:1.9.1": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -4482,6 +4621,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.3 + resolution: "jose@npm:6.2.3" + checksum: 10/876974613c5ee988d43b65a34c96ce440dbf7706a2f07f465b8874af16ee532102e224459a7068d2c6ef044affe49690667d23ca12770c279804baec95a09608 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4563,6 +4709,20 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10/02f2f466cdb0362558b2f1fd5e15cce82ef55d60cd7f8fa828cf35ba74330f8d767fcae5c5c2adb7851fa811766c694b9405810879bc4e1ddd78a7c0e03658ad + languageName: node + linkType: hard + +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10/fa866d1fe91e3a94aa4fe007861475cd03dcaf47b719861cab171ef2f8598478007c634d29ae45de94ee34ddff4e13414c63ea5ff06c5b868b613142c699d511 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -5179,7 +5339,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: 10/fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -5441,6 +5601,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10/51d11f68d5a78617cfb2e9c2706dadcc2cbe55ffb55b21d42a6ed848ac5159db2657bf6c966a5a414119aa839ceb64240afea35e9e1c06946b57606ed0b43789 + languageName: node + linkType: hard + "playwright-core@npm:1.58.1": version: 1.58.1 resolution: "playwright-core@npm:1.58.1" @@ -5588,7 +5755,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:^3.0.1": +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": version: 3.0.2 resolution: "raw-body@npm:3.0.2" dependencies: @@ -5649,6 +5816,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10/839a3a890102a658f4cb3e7b2aa13a1f80a3a976b512020c3d1efc418491c48a886b6e481ea56afc6c4cb5eef678f23b2a4e70575e7534eccadf5e30ed2e56eb + languageName: node + linkType: hard + "require-package-name@npm:^2.0.1": version: 2.0.1 resolution: "require-package-name@npm:2.0.1" @@ -6815,7 +6989,7 @@ __metadata: languageName: node linkType: hard -"vary@npm:^1.1.2": +"vary@npm:^1, vary@npm:^1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: 10/31389debef15a480849b8331b220782230b9815a8e0dbb7b9a8369559aed2e9a7800cd904d4371ea74f4c3527db456dc8e7ac5befce5f0d289014dbdf47b2242 @@ -7216,10 +7390,19 @@ __metadata: languageName: node linkType: hard -"zod@npm:^4.3.5": - version: 4.3.6 - resolution: "zod@npm:4.3.6" - checksum: 10/25fc0f62e01b557b4644bf0b393bbaf47542ab30877c37837ea8caf314a8713d220c7d7fe51f68ffa72f0e1018ddfa34d96f1973d23033f5a2a5a9b6b9d9da01 +"zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + +"zod@npm:^3.25 || ^4.0, zod@npm:^4.4.3": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 languageName: node linkType: hard From 104391989586ad95546d24ba206f4a9e3404098f Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Fri, 5 Jun 2026 19:11:33 +0200 Subject: [PATCH 2/9] chore: wip --- src/platform/mobile-platform-driver.test.ts | 287 ++++++++++++++++++++ src/platform/mobile-platform-driver.ts | 171 +++++++++++- src/platform/types.ts | 60 ++++ vitest.config.mts | 2 +- 4 files changed, 517 insertions(+), 3 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index 6bd5d7e..0248a75 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -448,4 +448,291 @@ describe('MobilePlatformDriver', () => { expect(backend.getAppState).toHaveBeenCalledWith('com.example.app'); }); }); + + describe('swipe', () => { + it('delegates direction and optional params', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.swipe('up', 100, 200, 500); + + expect(backend.swipe).toHaveBeenCalledWith('up', 100, 200, 500); + }); + + it('works with direction only', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.swipe('down'); + + expect(backend.swipe).toHaveBeenCalledWith( + 'down', + undefined, + undefined, + undefined, + ); + }); + }); + + describe('scrollToElement', () => { + it('resolves target and delegates', async () => { + const backend = createMockBackend({ + scrollToElement: vi.fn().mockResolvedValue(makeElement()), + }); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'identifier:footer']]); + + await driver.scrollToElement('a11yRef', 'e1', refMap, 'down', 5); + + expect(backend.scrollToElement).toHaveBeenCalledWith( + { identifier: 'footer' }, + 'down', + 5, + ); + }); + + it('resolves testId target', async () => { + const backend = createMockBackend({ + scrollToElement: vi.fn().mockResolvedValue(makeElement()), + }); + const driver = new MobilePlatformDriver(backend); + + await driver.scrollToElement('testId', 'terms-section', new Map()); + + expect(backend.scrollToElement).toHaveBeenCalledWith( + { identifier: 'terms-section' }, + undefined, + undefined, + ); + }); + }); + + describe('longPress', () => { + it('resolves target and delegates with duration', async () => { + const backend = createMockBackend({ + longPress: vi.fn().mockResolvedValue({ + success: true, + x: 50, + y: 50, + targetDescription: 'Button', + }), + }); + const driver = new MobilePlatformDriver(backend); + + await driver.longPress('testId', 'token-row', new Map(), 2000); + + expect(backend.longPress).toHaveBeenCalledWith( + { identifier: 'token-row' }, + 2000, + ); + }); + }); + + describe('tapCoordinates', () => { + it('delegates x and y', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.tapCoordinates(150, 300); + + expect(backend.tapCoordinates).toHaveBeenCalledWith(150, 300); + }); + }); + + describe('dismissKeyboard', () => { + it('delegates to backend', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.dismissKeyboard(); + + expect(backend.dismissKeyboard).toHaveBeenCalledOnce(); + }); + }); + + describe('dismissAlert', () => { + it('delegates accept=true', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.dismissAlert(true); + + expect(backend.dismissAlert).toHaveBeenCalledWith(true); + }); + + it('delegates accept=false', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.dismissAlert(false); + + expect(backend.dismissAlert).toHaveBeenCalledWith(false); + }); + }); + + describe('getAlertText', () => { + it('returns backend result', async () => { + const backend = createMockBackend({ + getAlertText: vi.fn().mockResolvedValue('Allow location access?'), + }); + const driver = new MobilePlatformDriver(backend); + + const text = await driver.getAlertText(); + + expect(text).toBe('Allow location access?'); + }); + }); + + describe('getWindowSize', () => { + it('returns backend dimensions', async () => { + const backend = createMockBackend({ + getWindowSize: vi.fn().mockResolvedValue({ width: 390, height: 844 }), + }); + const driver = new MobilePlatformDriver(backend); + + const size = await driver.getWindowSize(); + + expect(size).toStrictEqual({ width: 390, height: 844 }); + }); + }); + + describe('openApp', () => { + it('delegates bundleId', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.openApp('io.metamask'); + + expect(backend.openApp).toHaveBeenCalledWith('io.metamask'); + }); + }); + + describe('closeApp', () => { + it('delegates bundleId', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.closeApp('io.metamask'); + + expect(backend.closeApp).toHaveBeenCalledWith('io.metamask'); + }); + }); + + describe('pressButton', () => { + it('delegates button name', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.pressButton('home'); + + expect(backend.pressButton).toHaveBeenCalledWith('home'); + }); + }); + + describe('getDeviceContexts', () => { + it('returns backend contexts', async () => { + const backend = createMockBackend({ + getContexts: vi + .fn() + .mockResolvedValue(['NATIVE_APP', 'WEBVIEW_1234']), + }); + const driver = new MobilePlatformDriver(backend); + + const contexts = await driver.getDeviceContexts(); + + expect(contexts).toStrictEqual(['NATIVE_APP', 'WEBVIEW_1234']); + }); + }); + + describe('setDeviceContext', () => { + it('delegates context name', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.setDeviceContext('WEBVIEW_1234'); + + expect(backend.setContext).toHaveBeenCalledWith('WEBVIEW_1234'); + }); + }); + + describe('getClipboard', () => { + it('returns backend clipboard text', async () => { + const backend = createMockBackend({ + getClipboard: vi.fn().mockResolvedValue('0x1234abcd'), + }); + const driver = new MobilePlatformDriver(backend); + + const text = await driver.getClipboard(); + + expect(text).toBe('0x1234abcd'); + }); + }); + + describe('setClipboard', () => { + it('delegates text', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.setClipboard('0x1234abcd'); + + expect(backend.setClipboard).toHaveBeenCalledWith('0x1234abcd'); + }); + }); + + describe('startScreenRecording', () => { + it('delegates with output path', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.startScreenRecording('/tmp/recording.mp4'); + + expect(backend.startScreenRecording).toHaveBeenCalledWith( + '/tmp/recording.mp4', + ); + }); + + it('works without output path', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + await driver.startScreenRecording(); + + expect(backend.startScreenRecording).toHaveBeenCalledWith(undefined); + }); + }); + + describe('stopScreenRecording', () => { + it('returns file path from backend', async () => { + const backend = createMockBackend({ + stopScreenRecording: vi + .fn() + .mockResolvedValue('/tmp/recording.mp4'), + }); + const driver = new MobilePlatformDriver(backend); + + const path = await driver.stopScreenRecording(); + + expect(path).toBe('/tmp/recording.mp4'); + }); + }); + + describe('getLogs', () => { + it('delegates params and returns result', async () => { + const logsResult = { + entries: [ + { timestamp: '2025-01-01T00:00:00Z', level: 'info', message: 'ok' }, + ], + source: 'syslog', + }; + const backend = createMockBackend({ + getLogs: vi.fn().mockResolvedValue(logsResult), + }); + const driver = new MobilePlatformDriver(backend); + + const result = await driver.getLogs(30, 'MetaMask'); + + expect(backend.getLogs).toHaveBeenCalledWith(30, 'MetaMask'); + expect(result).toStrictEqual(logsResult); + }); + }); }); diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index cd6d321..461479d 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -1,5 +1,6 @@ import type { DeviceBackend, + DeviceButton, ElementQuery, UIElement, } from '@metamask/device-mcp'; @@ -20,8 +21,9 @@ import type { } from '../capabilities/types.js'; import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; +import { OBSERVATION_TESTID_LIMIT } from '../tools/utils/constants.js'; + const DEFAULT_BUNDLE_ID = 'io.metamask'; -const DEFAULT_TESTID_LIMIT = 150; /** * Platform driver for mobile devices backed by @metamask/device-mcp. @@ -153,7 +155,7 @@ export class MobilePlatformDriver implements IPlatformDriver { async getTestIds(limit?: number): Promise { const snapshot = await this.#backend.snapshot(); const items: TestIdItem[] = []; - const max = limit ?? DEFAULT_TESTID_LIMIT; + const max = limit ?? OBSERVATION_TESTID_LIMIT; collectTestIds(snapshot.hierarchy, items, max); return items; } @@ -206,6 +208,171 @@ export class MobilePlatformDriver implements IPlatformDriver { getPlatform(): PlatformType { return this.#backend.platform; } + + // ---- Mobile-specific ---- + + /** + * @param direction - Swipe direction. + * @param startX - Optional start X coordinate. + * @param startY - Optional start Y coordinate. + * @param distance - Optional swipe distance in pixels. + */ + async swipe( + direction: 'up' | 'down' | 'left' | 'right', + startX?: number, + startY?: number, + distance?: number, + ): Promise { + await this.#backend.swipe(direction, startX, startY, distance); + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param refMap - Map of a11y refs to stable identifiers. + * @param direction - Scroll direction (default: down). + * @param maxAttempts - Maximum scroll attempts. + */ + async scrollToElement( + targetType: TargetType, + targetValue: string, + refMap: Map, + direction?: 'up' | 'down', + maxAttempts?: number, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.scrollToElement(query, direction, maxAttempts); + } + + /** + * @param targetType - Target identifier type. + * @param targetValue - Target value for element lookup. + * @param refMap - Map of a11y refs to stable identifiers. + * @param durationMs - Press duration in milliseconds. + */ + async longPress( + targetType: TargetType, + targetValue: string, + refMap: Map, + durationMs?: number, + ): Promise { + const query = resolveTargetToQuery(targetType, targetValue, refMap); + await this.#backend.longPress(query, durationMs); + } + + /** + * @param x - X coordinate in pixels. + * @param y - Y coordinate in pixels. + */ + async tapCoordinates(x: number, y: number): Promise { + await this.#backend.tapCoordinates(x, y); + } + + /** + * Hides the on-screen keyboard. + */ + async dismissKeyboard(): Promise { + await this.#backend.dismissKeyboard(); + } + + /** + * @param accept - True to accept, false to dismiss. + */ + async dismissAlert(accept: boolean): Promise { + await this.#backend.dismissAlert(accept); + } + + /** + * @returns The text content of the current system alert. + */ + async getAlertText(): Promise { + return this.#backend.getAlertText(); + } + + /** + * @returns The device screen dimensions. + */ + async getWindowSize(): Promise<{ width: number; height: number }> { + return this.#backend.getWindowSize(); + } + + /** + * @param bundleId - App bundle ID to launch. + */ + async openApp(bundleId: string): Promise { + await this.#backend.openApp(bundleId); + } + + /** + * @param bundleId - App bundle ID to terminate. + */ + async closeApp(bundleId: string): Promise { + await this.#backend.closeApp(bundleId); + } + + /** + * @param button - Device button name (home, back, enter, lock). + */ + async pressButton(button: string): Promise { + await this.#backend.pressButton(button as DeviceButton); + } + + /** + * @returns Available app contexts (NATIVE_APP, WEBVIEW_*, etc.). + */ + async getDeviceContexts(): Promise { + return this.#backend.getContexts(); + } + + /** + * @param contextName - Context to switch to (e.g. NATIVE_APP, WEBVIEW_1). + */ + async setDeviceContext(contextName: string): Promise { + await this.#backend.setContext(contextName); + } + + /** + * @returns Current clipboard text. + */ + async getClipboard(): Promise { + return this.#backend.getClipboard(); + } + + /** + * @param text - Text to write to the device clipboard. + */ + async setClipboard(text: string): Promise { + await this.#backend.setClipboard(text); + } + + /** + * @param outputPath - Optional file path for the recording. + */ + async startScreenRecording(outputPath?: string): Promise { + await this.#backend.startScreenRecording(outputPath); + } + + /** + * @returns File path of the saved recording. + */ + async stopScreenRecording(): Promise { + return this.#backend.stopScreenRecording(); + } + + /** + * @param durationSeconds - Seconds of logs to retrieve. + * @param filter - Text pattern to filter log entries. + * @returns Filtered log entries. + */ + async getLogs( + durationSeconds?: number, + filter?: string, + ): Promise<{ + entries: { timestamp: string; level: string; message: string }[]; + source: string; + }> { + return this.#backend.getLogs(durationSeconds, filter); + } } /** diff --git a/src/platform/types.ts b/src/platform/types.ts index 8085c1e..c400d32 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -85,4 +85,64 @@ export type IPlatformDriver = { getCurrentUrl(): string; getPlatform(): PlatformType; + + // ---- Mobile-specific (optional) ---- + + swipe?( + direction: 'up' | 'down' | 'left' | 'right', + startX?: number, + startY?: number, + distance?: number, + ): Promise; + + scrollToElement?( + targetType: TargetType, + targetValue: string, + refMap: Map, + direction?: 'up' | 'down', + maxAttempts?: number, + ): Promise; + + longPress?( + targetType: TargetType, + targetValue: string, + refMap: Map, + durationMs?: number, + ): Promise; + + tapCoordinates?(x: number, y: number): Promise; + + dismissKeyboard?(): Promise; + + dismissAlert?(accept: boolean): Promise; + + getAlertText?(): Promise; + + getWindowSize?(): Promise<{ width: number; height: number }>; + + openApp?(bundleId: string): Promise; + + closeApp?(bundleId: string): Promise; + + pressButton?(button: string): Promise; + + getDeviceContexts?(): Promise; + + setDeviceContext?(contextName: string): Promise; + + getClipboard?(): Promise; + + setClipboard?(text: string): Promise; + + startScreenRecording?(outputPath?: string): Promise; + + stopScreenRecording?(): Promise; + + getLogs?( + durationSeconds?: number, + filter?: string, + ): Promise<{ + entries: { timestamp: string; level: string; message: string }[]; + source: string; + }>; }; diff --git a/vitest.config.mts b/vitest.config.mts index 077a3fc..b040f50 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -36,7 +36,7 @@ export default defineConfig({ // Disabled in CI to prevent non-deterministic config changes. autoUpdate: !process.env.CI, branches: 89.52, - functions: 92.62, + functions: 92.94, lines: 95.5, statements: 95.21, }, From 7a2dc4708d2e3697cf9d6c0f992d4d0310b08dd2 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Fri, 5 Jun 2026 19:29:22 +0200 Subject: [PATCH 3/9] chore: lint --- src/platform/mobile-platform-driver.test.ts | 54 ++++++++++++++++++--- src/platform/mobile-platform-driver.ts | 24 ++------- vitest.config.mts | 6 +-- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index 0248a75..4a6c4c8 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -139,6 +139,30 @@ describe('MobilePlatformDriver', () => { driver.click('a11yRef', 'e99', new Map(), 5000), ).rejects.toThrowError('Unknown a11yRef: e99'); }); + + it('resolves a11yRef with no-colon stable id as identifier', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'bare-id']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'bare-id', + }); + }); + + it('resolves a11yRef with unknown prefix as identifier', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'custom:some-value']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'custom:some-value', + }); + }); }); describe('type', () => { @@ -355,6 +379,28 @@ describe('MobilePlatformDriver', () => { }); }); + it('collects identifiers from nested children', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ + type: 'Window', + children: [makeElement({ identifier: 'child-btn', label: 'OK' })], + }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const items = await driver.getTestIds(); + + expect(items).toHaveLength(1); + expect(items[0].testId).toBe('child-btn'); + }); + it('respects limit', async () => { const backend = createMockBackend({ snapshot: vi.fn().mockResolvedValue({ @@ -632,9 +678,7 @@ describe('MobilePlatformDriver', () => { describe('getDeviceContexts', () => { it('returns backend contexts', async () => { const backend = createMockBackend({ - getContexts: vi - .fn() - .mockResolvedValue(['NATIVE_APP', 'WEBVIEW_1234']), + getContexts: vi.fn().mockResolvedValue(['NATIVE_APP', 'WEBVIEW_1234']), }); const driver = new MobilePlatformDriver(backend); @@ -704,9 +748,7 @@ describe('MobilePlatformDriver', () => { describe('stopScreenRecording', () => { it('returns file path from backend', async () => { const backend = createMockBackend({ - stopScreenRecording: vi - .fn() - .mockResolvedValue('/tmp/recording.mp4'), + stopScreenRecording: vi.fn().mockResolvedValue('/tmp/recording.mp4'), }); const driver = new MobilePlatformDriver(backend); diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 461479d..4990157 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -5,22 +5,6 @@ import type { UIElement, } from '@metamask/device-mcp'; -import type { - IPlatformDriver, - TargetType, - ClickActionResult, - TypeActionResult, - GetTextActionResult, - PlatformScreenshotOptions, - PlatformType, - WithinScope, -} from './types.js'; -import type { - ScreenshotResult, - ExtensionState, -} from '../capabilities/types.js'; -import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; - import { OBSERVATION_TESTID_LIMIT } from '../tools/utils/constants.js'; const DEFAULT_BUNDLE_ID = 'io.metamask'; @@ -261,11 +245,11 @@ export class MobilePlatformDriver implements IPlatformDriver { } /** - * @param x - X coordinate in pixels. - * @param y - Y coordinate in pixels. + * @param coordX - X coordinate in pixels. + * @param coordY - Y coordinate in pixels. */ - async tapCoordinates(x: number, y: number): Promise { - await this.#backend.tapCoordinates(x, y); + async tapCoordinates(coordX: number, coordY: number): Promise { + await this.#backend.tapCoordinates(coordX, coordY); } /** diff --git a/vitest.config.mts b/vitest.config.mts index b040f50..91e526d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -35,10 +35,10 @@ export default defineConfig({ // Auto-update the coverage thresholds when running locally. // Disabled in CI to prevent non-deterministic config changes. autoUpdate: !process.env.CI, - branches: 89.52, + branches: 89.53, functions: 92.94, - lines: 95.5, - statements: 95.21, + lines: 95.59, + statements: 95.31, }, }, From fea4472808f520b9b0f994f96b5b4eb38ae80528 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Sat, 6 Jun 2026 12:21:47 +0200 Subject: [PATCH 4/9] chore: lint fix --- src/platform/mobile-platform-driver.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 4990157..cc29e83 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -5,6 +5,21 @@ import type { UIElement, } from '@metamask/device-mcp'; +import type { + IPlatformDriver, + TargetType, + ClickActionResult, + TypeActionResult, + GetTextActionResult, + PlatformScreenshotOptions, + PlatformType, + WithinScope, +} from './types.js'; +import type { + ScreenshotResult, + ExtensionState, +} from '../capabilities/types.js'; +import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; import { OBSERVATION_TESTID_LIMIT } from '../tools/utils/constants.js'; const DEFAULT_BUNDLE_ID = 'io.metamask'; From ecfb2033b452134e60bb21faa18b233b5be76941 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Mon, 8 Jun 2026 13:13:58 +0200 Subject: [PATCH 5/9] chore: dedupe --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6ffbc86..9db8cdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4438,14 +4438,7 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^10.0.1": - version: 10.1.0 - resolution: "ip-address@npm:10.1.0" - checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e - languageName: node - linkType: hard - -"ip-address@npm:^10.2.0": +"ip-address@npm:^10.0.1, ip-address@npm:^10.2.0": version: 10.2.0 resolution: "ip-address@npm:10.2.0" checksum: 10/12fec399e1af5753ac322e47a6d81a50d3a528b3abb17c09525b2a2edcaedcca628c40520706f7037bc4d8e951b0296c47e7b86d0a8e6e2335c8f0ba4afcfac1 From 7bc0206e986473de6fa03b3a6a93ded9e1b52902 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Thu, 11 Jun 2026 17:22:14 +0200 Subject: [PATCH 6/9] chore: address review --- src/platform/mobile-platform-driver.test.ts | 174 ++++++++++++++++++-- src/platform/mobile-platform-driver.ts | 125 +++++++++++--- src/tools/platform-gating.test.ts | 4 +- src/tools/registry.test.ts | 29 +++- src/tools/registry.ts | 3 + src/tools/types/discovery.ts | 1 + vitest.config.mts | 8 +- 7 files changed, 306 insertions(+), 38 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index 4a6c4c8..a096bd5 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -121,15 +121,12 @@ describe('MobilePlatformDriver', () => { }); }); - it('resolves selector as identifier', async () => { - const backend = createMockBackend(); - const driver = new MobilePlatformDriver(backend); - - await driver.click('selector', 'my-element', new Map(), 5000); + it('throws for selector target type', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); - expect(backend.tapElement).toHaveBeenCalledWith({ - identifier: 'my-element', - }); + await expect( + driver.click('selector', 'my-element', new Map(), 5000), + ).rejects.toThrowError('CSS selectors are not supported on mobile'); }); it('throws for unknown a11yRef', async () => { @@ -163,6 +160,53 @@ describe('MobilePlatformDriver', () => { identifier: 'custom:some-value', }); }); + + it('throws when within scope is provided', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.click('testId', 'btn', new Map(), 5000, { + type: 'testId', + value: 'parent', + }), + ).rejects.toThrowError('not supported on mobile'); + }); + + it('resolves label|type stable id via parseStableIdentifier', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'label:OK|type:Button']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ label: 'OK' }); + }); + + it('throws when timeout budget is exceeded after waitForElement', async () => { + const backend = createMockBackend({ + waitForElement: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 50)), + ), + }); + const driver = new MobilePlatformDriver(backend); + + await expect( + driver.click('testId', 'btn', new Map(), 1), + ).rejects.toThrowError('Timeout exceeded'); + }); + + it('throws when tapElement exceeds remaining budget', async () => { + const backend = createMockBackend({ + tapElement: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 200)), + ), + }); + const driver = new MobilePlatformDriver(backend); + + await expect( + driver.click('testId', 'btn', new Map(), 50), + ).rejects.toThrowError('Timeout exceeded during tapElement'); + }); }); describe('type', () => { @@ -192,6 +236,17 @@ describe('MobilePlatformDriver', () => { textLength: 9, }); }); + + it('throws when within scope is provided', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.type('testId', 'input', 'text', new Map(), 5000, { + type: 'testId', + value: 'parent', + }), + ).rejects.toThrowError('not supported on mobile'); + }); }); describe('waitForElement', () => { @@ -207,6 +262,17 @@ describe('MobilePlatformDriver', () => { 10000, ); }); + + it('throws when within scope is provided', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.waitForElement('testId', 'el', new Map(), 5000, { + type: 'testId', + value: 'parent', + }), + ).rejects.toThrowError('not supported on mobile'); + }); }); describe('getText', () => { @@ -242,6 +308,17 @@ describe('MobilePlatformDriver', () => { text: '0.5 ETH', }); }); + + it('throws when within scope is provided', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.getText('testId', 'el', new Map(), 5000, { + type: 'testId', + value: 'parent', + }), + ).rejects.toThrowError('not supported on mobile'); + }); }); describe('getAccessibilityTree', () => { @@ -287,7 +364,7 @@ describe('MobilePlatformDriver', () => { textContent: 'user@test.com', }); expect(refMap.get('e1')).toBe('identifier:submit-btn'); - expect(refMap.get('e2')).toBe('label:Email'); + expect(refMap.get('e2')).toBe('label:Email|type:TextField'); }); it('assigns sequential refs to nested children', async () => { @@ -334,7 +411,69 @@ describe('MobilePlatformDriver', () => { const { refMap } = await driver.getAccessibilityTree(); - expect(refMap.get('e1')).toBe('value:0.5 ETH'); + expect(refMap.get('e1')).toBe('value:0.5 ETH|type:StaticText'); + }); + + it('flags ambiguous nodes with duplicate stable ids', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ type: 'Button', label: 'OK' }), + makeElement({ type: 'Button', label: 'OK' }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes } = await driver.getAccessibilityTree(); + + expect(nodes[0].ambiguous).toBe(true); + expect(nodes[1].ambiguous).toBe(true); + }); + + it('does not flag nodes with same label but different types', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ type: 'Button', label: 'OK' }), + makeElement({ type: 'Link', label: 'OK' }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes, refMap } = await driver.getAccessibilityTree(); + + expect(refMap.get('e1')).toBe('label:OK|type:Button'); + expect(refMap.get('e2')).toBe('label:OK|type:Link'); + expect(nodes[0].ambiguous).toBeUndefined(); + expect(nodes[1].ambiguous).toBeUndefined(); + }); + + it('does not flag identifier-based refs', async () => { + const backend = createMockBackend({ + snapshot: vi.fn().mockResolvedValue({ + platform: 'ios', + hierarchy: [ + makeElement({ type: 'Button', identifier: 'ok-btn', label: 'OK' }), + makeElement({ type: 'Button', label: 'OK' }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes } = await driver.getAccessibilityTree(); + + expect(nodes[0].ambiguous).toBeUndefined(); + expect(nodes[1].ambiguous).toBeUndefined(); }); }); @@ -470,6 +609,21 @@ describe('MobilePlatformDriver', () => { }); }); + it('returns isLoaded true for Appium foreground state code', async () => { + const backend = createMockBackend({ + getAppState: vi.fn().mockResolvedValue({ + bundleId: 'io.metamask', + state: '4', + }), + }); + const driver = new MobilePlatformDriver(backend); + + const state = await driver.getAppState(); + + expect(state.isLoaded).toBe(true); + expect(state.isUnlocked).toBe(true); + }); + it('returns isLoaded false for non-running app', async () => { const backend = createMockBackend({ getAppState: vi.fn().mockResolvedValue({ diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index cc29e83..551dabd 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -23,6 +23,7 @@ import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; import { OBSERVATION_TESTID_LIMIT } from '../tools/utils/constants.js'; const DEFAULT_BUNDLE_ID = 'io.metamask'; +const APPIUM_STATE_RUNNING_IN_FOREGROUND = '4'; /** * Platform driver for mobile devices backed by @metamask/device-mcp. @@ -49,7 +50,7 @@ export class MobilePlatformDriver implements IPlatformDriver { * @param targetValue - Target value for element lookup. * @param refMap - Map of a11y refs to stable identifiers. * @param timeoutMs - Maximum wait time in milliseconds. - * @param _within - Unused on mobile — elements are always searched globally. + * @param within - Must be undefined — scoped search is not supported on mobile. * @returns Click result with success status and target info. */ async click( @@ -57,11 +58,14 @@ export class MobilePlatformDriver implements IPlatformDriver { targetValue: string, refMap: Map, timeoutMs: number, - _within?: WithinScope, + within?: WithinScope, ): Promise { + rejectWithinScope(within); const query = resolveTargetToQuery(targetType, targetValue, refMap); + const deadline = Date.now() + timeoutMs; await this.#backend.waitForElement(query, timeoutMs); - await this.#backend.tapElement(query); + const remaining = deadline - Date.now(); + await withTimeout(this.#backend.tapElement(query), remaining, 'tapElement'); return { clicked: true, target: `${targetType}:${targetValue}` }; } @@ -71,7 +75,7 @@ export class MobilePlatformDriver implements IPlatformDriver { * @param text - Text to type into the element. * @param refMap - Map of a11y refs to stable identifiers. * @param timeoutMs - Maximum wait time in milliseconds. - * @param _within - Unused on mobile. + * @param within - Must be undefined — scoped search is not supported on mobile. * @returns Type result with success status and text length. */ async type( @@ -80,12 +84,16 @@ export class MobilePlatformDriver implements IPlatformDriver { text: string, refMap: Map, timeoutMs: number, - _within?: WithinScope, + within?: WithinScope, ): Promise { + rejectWithinScope(within); const query = resolveTargetToQuery(targetType, targetValue, refMap); + const deadline = Date.now() + timeoutMs; await this.#backend.waitForElement(query, timeoutMs); - await this.#backend.tapElement(query); - await this.#backend.typeText(text); + let remaining = deadline - Date.now(); + await withTimeout(this.#backend.tapElement(query), remaining, 'tapElement'); + remaining = deadline - Date.now(); + await withTimeout(this.#backend.typeText(text), remaining, 'typeText'); return { typed: true, target: `${targetType}:${targetValue}`, @@ -98,15 +106,16 @@ export class MobilePlatformDriver implements IPlatformDriver { * @param targetValue - Target value for element lookup. * @param refMap - Map of a11y refs to stable identifiers. * @param timeoutMs - Maximum wait time in milliseconds. - * @param _within - Unused on mobile. + * @param within - Must be undefined — scoped search is not supported on mobile. */ async waitForElement( targetType: TargetType, targetValue: string, refMap: Map, timeoutMs: number, - _within?: WithinScope, + within?: WithinScope, ): Promise { + rejectWithinScope(within); const query = resolveTargetToQuery(targetType, targetValue, refMap); await this.#backend.waitForElement(query, timeoutMs); } @@ -116,7 +125,7 @@ export class MobilePlatformDriver implements IPlatformDriver { * @param targetValue - Target value for element lookup. * @param refMap - Map of a11y refs to stable identifiers. * @param timeoutMs - Maximum wait time in milliseconds. - * @param _within - Unused on mobile. + * @param within - Must be undefined — scoped search is not supported on mobile. * @returns The element's text content, target descriptor, and length. */ async getText( @@ -124,11 +133,18 @@ export class MobilePlatformDriver implements IPlatformDriver { targetValue: string, refMap: Map, timeoutMs: number, - _within?: WithinScope, + within?: WithinScope, ): Promise { + rejectWithinScope(within); const query = resolveTargetToQuery(targetType, targetValue, refMap); + const deadline = Date.now() + timeoutMs; await this.#backend.waitForElement(query, timeoutMs); - const text = await this.#backend.getElementText(query); + const remaining = deadline - Date.now(); + const text = await withTimeout( + this.#backend.getElementText(query), + remaining, + 'getElementText', + ); return { text, target: `${targetType}:${targetValue}`, @@ -180,7 +196,9 @@ export class MobilePlatformDriver implements IPlatformDriver { */ async getAppState(): Promise { const appState = await this.#backend.getAppState(this.#bundleId); - const isRunning = appState.state === 'Running' || appState.state === '4'; + const isRunning = + appState.state === 'Running' || + appState.state === APPIUM_STATE_RUNNING_IN_FOREGROUND; return { isLoaded: isRunning, currentUrl: '', @@ -401,7 +419,9 @@ function resolveTargetToQuery( case 'testId': return { identifier: targetValue }; case 'selector': - return { identifier: targetValue }; + throw new Error( + 'CSS selectors are not supported on mobile. Use testId or a11yRef instead.', + ); default: { const _exhaustive: never = targetType; throw new Error(`Unknown target type: ${_exhaustive as string}`); @@ -416,12 +436,15 @@ function resolveTargetToQuery( * @returns The corresponding ElementQuery. */ function parseStableIdentifier(stableId: string): ElementQuery { - const colonIndex = stableId.indexOf(':'); + const pipeIndex = stableId.indexOf('|'); + const core = pipeIndex >= 0 ? stableId.slice(0, pipeIndex) : stableId; + + const colonIndex = core.indexOf(':'); if (colonIndex < 0) { - return { identifier: stableId }; + return { identifier: core }; } - const prefix = stableId.slice(0, colonIndex); - const value = stableId.slice(colonIndex + 1); + const prefix = core.slice(0, colonIndex); + const value = core.slice(colonIndex + 1); switch (prefix) { case 'identifier': @@ -431,7 +454,7 @@ function parseStableIdentifier(stableId: string): ElementQuery { case 'value': return { text: value }; default: - return { identifier: stableId }; + return { identifier: core }; } } @@ -447,6 +470,7 @@ function normalizeSnapshot(hierarchy: UIElement[]): { } { const nodes: A11yNodeTrimmed[] = []; const refMap = new Map(); + const stableIdCount = new Map(); let refCounter = 0; /** @@ -472,12 +496,20 @@ function normalizeSnapshot(hierarchy: UIElement[]): { } nodes.push(node); + let stableId: string | undefined; if (el.identifier) { - refMap.set(ref, `identifier:${el.identifier}`); + stableId = `identifier:${el.identifier}`; } else if (el.label) { - refMap.set(ref, `label:${el.label}`); + stableId = `label:${el.label}|type:${role}`; } else if (el.value) { - refMap.set(ref, `value:${el.value}`); + stableId = `value:${el.value}|type:${role}`; + } + + if (stableId) { + refMap.set(ref, stableId); + const refs = stableIdCount.get(stableId) ?? []; + refs.push(ref); + stableIdCount.set(stableId, refs); } if (el.children?.length) { @@ -487,6 +519,18 @@ function normalizeSnapshot(hierarchy: UIElement[]): { } walk(hierarchy, []); + + for (const refs of stableIdCount.values()) { + if (refs.length > 1) { + for (const ref of refs) { + const node = nodes.find((nd) => nd.ref === ref); + if (node) { + node.ambiguous = true; + } + } + } + } + return { nodes, refMap }; } @@ -519,3 +563,40 @@ function collectTestIds( } } } + +/** + * @param within - The within scope to validate. + */ +function rejectWithinScope(within: WithinScope | undefined): void { + if (within) { + throw new Error( + 'Scoped element search (within) is not supported on mobile. ' + + 'Target elements directly by testId or a11yRef.', + ); + } +} + +/** + * @param promise - The operation to race against the deadline. + * @param remainingMs - Milliseconds remaining in the timeout budget. + * @param operationName - Label for the timeout error message. + * @returns The result of the promise if it resolves within the budget. + */ +async function withTimeout( + promise: Promise, + remainingMs: number, + operationName: string, +): Promise { + if (remainingMs <= 0) { + throw new Error(`Timeout exceeded before ${operationName}`); + } + return Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error(`Timeout exceeded during ${operationName}`)), + remainingMs, + ), + ), + ]); +} diff --git a/src/tools/platform-gating.test.ts b/src/tools/platform-gating.test.ts index a2ff076..3f13606 100644 --- a/src/tools/platform-gating.test.ts +++ b/src/tools/platform-gating.test.ts @@ -9,6 +9,9 @@ describe('isBrowserOnlyTool', () => { expect(isBrowserOnlyTool('close_tab')).toBe(true); expect(isBrowserOnlyTool('wait_for_notification')).toBe(true); expect(isBrowserOnlyTool('cdp')).toBe(true); + expect(isBrowserOnlyTool('clipboard')).toBe(true); + expect(isBrowserOnlyTool('mock_network')).toBe(true); + expect(isBrowserOnlyTool('build')).toBe(true); }); it('returns false for cross-platform tools', () => { @@ -23,7 +26,6 @@ describe('isBrowserOnlyTool', () => { expect(isBrowserOnlyTool('list_testids')).toBe(false); expect(isBrowserOnlyTool('accessibility_snapshot')).toBe(false); expect(isBrowserOnlyTool('get_text')).toBe(false); - expect(isBrowserOnlyTool('clipboard')).toBe(false); }); it('returns false for unknown tools', () => { diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts index 19d51b4..9bd1316 100644 --- a/src/tools/registry.test.ts +++ b/src/tools/registry.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { toolRegistry, TOOL_CATEGORIES, getToolCategory } from './registry.js'; +import { + toolRegistry, + TOOL_CATEGORIES, + getToolCategory, + isBrowserOnlyTool, +} from './registry.js'; describe('toolRegistry', () => { it('has expected tool entries', () => { @@ -81,3 +86,25 @@ describe('TOOL_CATEGORIES and getToolCategory', () => { expect(getToolCategory('run_steps')).toBe('batch'); }); }); + +describe('isBrowserOnlyTool', () => { + it.each([ + 'navigate', + 'switch_to_tab', + 'close_tab', + 'wait_for_notification', + 'cdp', + 'clipboard', + 'mock_network', + 'build', + ])('returns true for %s', (toolName) => { + expect(isBrowserOnlyTool(toolName)).toBe(true); + }); + + it.each(['click', 'type', 'launch', 'screenshot', 'get_text'])( + 'returns false for %s', + (toolName) => { + expect(isBrowserOnlyTool(toolName)).toBe(false); + }, + ); +}); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4ef04a4..f03444f 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -136,6 +136,9 @@ const BROWSER_ONLY_TOOLS = new Set([ 'close_tab', 'wait_for_notification', 'cdp', + 'clipboard', + 'mock_network', + 'build', ]); /** diff --git a/src/tools/types/discovery.ts b/src/tools/types/discovery.ts index abb2682..cffe420 100644 --- a/src/tools/types/discovery.ts +++ b/src/tools/types/discovery.ts @@ -54,6 +54,7 @@ export type A11yNodeTrimmed = { path: string[]; testId?: string; textContent?: string; + ambiguous?: boolean; }; export type RawA11yNode = { diff --git a/vitest.config.mts b/vitest.config.mts index 91e526d..655840f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -35,10 +35,10 @@ export default defineConfig({ // Auto-update the coverage thresholds when running locally. // Disabled in CI to prevent non-deterministic config changes. autoUpdate: !process.env.CI, - branches: 89.53, - functions: 92.94, - lines: 95.59, - statements: 95.31, + branches: 89.5, + functions: 93.02, + lines: 95.64, + statements: 95.36, }, }, From afb3633342842a1e54ac606f5b048d78f06928cb Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Thu, 18 Jun 2026 14:40:02 +0200 Subject: [PATCH 7/9] chore: fix lint --- src/platform/mobile-platform-driver.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index a096bd5..ce489ca 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -184,9 +184,11 @@ describe('MobilePlatformDriver', () => { it('throws when timeout budget is exceeded after waitForElement', async () => { const backend = createMockBackend({ - waitForElement: vi.fn().mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 50)), - ), + waitForElement: vi + .fn() + .mockImplementation( + async () => new Promise((resolve) => setTimeout(resolve, 50)), + ), }); const driver = new MobilePlatformDriver(backend); @@ -197,9 +199,11 @@ describe('MobilePlatformDriver', () => { it('throws when tapElement exceeds remaining budget', async () => { const backend = createMockBackend({ - tapElement: vi.fn().mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 200)), - ), + tapElement: vi + .fn() + .mockImplementation( + async () => new Promise((resolve) => setTimeout(resolve, 200)), + ), }); const driver = new MobilePlatformDriver(backend); From 08fb22f893e342b03e127551ceba01f70525f3b3 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Mon, 22 Jun 2026 10:06:46 +0200 Subject: [PATCH 8/9] fix: bump hono to 4.12.26 to resolve CVE-2026-54290 (CORS middleware) --- package.json | 3 ++- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 90cc46b..653eeca 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ }, "resolutions": { "@isaacs/brace-expansion": "5.0.1", - "fflate": "0.8.2" + "fflate": "0.8.2", + "hono": "^4.12.25" }, "dependencies": { "@metamask/device-mcp": "^0.2.0", diff --git a/yarn.lock b/yarn.lock index 9db8cdd..6a4f929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4291,10 +4291,10 @@ __metadata: languageName: node linkType: hard -"hono@npm:^4.11.4": - version: 4.12.23 - resolution: "hono@npm:4.12.23" - checksum: 10/721a7f0728fafe229ea84d5f0f3a2575b44dce5eb2047e49f4e7929257bb59511c80adefbe09637480ca3b65ad553510d7cd75c7dc696a916f141aefcbcb2667 +"hono@npm:^4.12.25": + version: 4.12.26 + resolution: "hono@npm:4.12.26" + checksum: 10/ae8c37718215c57814a8f244e594b49a461bce35e9ed3b50573a0c37ce86d196d448867447df6e940071e66888288749f780cdd8e59d8bd69d7e5aba6d105f7d languageName: node linkType: hard From 60843bf3bf0b6836d40c8ad2e43aad286501c706 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Tue, 30 Jun 2026 11:06:15 +0200 Subject: [PATCH 9/9] chore: address review bug --- src/platform/mobile-platform-driver.test.ts | 97 +++++++++++++++++++++ src/platform/mobile-platform-driver.ts | 32 +++++-- vitest.config.mts | 4 +- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index ce489ca..8ad6963 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -179,9 +179,106 @@ describe('MobilePlatformDriver', () => { await driver.click('a11yRef', 'e1', refMap, 5000); + expect(backend.tapElement).toHaveBeenCalledWith({ + label: 'OK', + type: 'Button', + }); + }); + + it('disambiguates same-label elements by type', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([ + ['e1', 'label:OK|type:Button'], + ['e2', 'label:OK|type:StaticText'], + ]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + expect(backend.tapElement).toHaveBeenCalledWith({ + label: 'OK', + type: 'Button', + }); + + await driver.click('a11yRef', 'e2', refMap, 5000); + expect(backend.tapElement).toHaveBeenCalledWith({ + label: 'OK', + type: 'StaticText', + }); + }); + + it('resolves value|type stable id with type in query', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'value:0.5 ETH|type:StaticText']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + text: '0.5 ETH', + type: 'StaticText', + }); + }); + + it('ignores unknown pipe segments in stable id', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'label:OK|unknown']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + expect(backend.tapElement).toHaveBeenCalledWith({ label: 'OK' }); }); + it('falls back to identifier for unknown prefix in stable id', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'custom:something|type:Button']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'custom:something', + type: 'Button', + }); + }); + + it('handles stable id with no pipe segments', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'identifier:submit-btn']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'submit-btn', + }); + }); + + it('treats bare string without colon as identifier', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'rawvalue']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + identifier: 'rawvalue', + }); + }); + + it('skips non-type keyed pipe segments in stable id', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + const refMap = new Map([['e1', 'label:OK|extra:info|type:Button']]); + + await driver.click('a11yRef', 'e1', refMap, 5000); + + expect(backend.tapElement).toHaveBeenCalledWith({ + label: 'OK', + type: 'Button', + }); + }); + it('throws when timeout budget is exceeded after waitForElement', async () => { const backend = createMockBackend({ waitForElement: vi diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 551dabd..7638afa 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -436,8 +436,8 @@ function resolveTargetToQuery( * @returns The corresponding ElementQuery. */ function parseStableIdentifier(stableId: string): ElementQuery { - const pipeIndex = stableId.indexOf('|'); - const core = pipeIndex >= 0 ? stableId.slice(0, pipeIndex) : stableId; + const segments = stableId.split('|'); + const core = segments[0] as string; const colonIndex = core.indexOf(':'); if (colonIndex < 0) { @@ -446,16 +446,36 @@ function parseStableIdentifier(stableId: string): ElementQuery { const prefix = core.slice(0, colonIndex); const value = core.slice(colonIndex + 1); + let query: ElementQuery; switch (prefix) { case 'identifier': - return { identifier: value }; + query = { identifier: value }; + break; case 'label': - return { label: value }; + query = { label: value }; + break; case 'value': - return { text: value }; + query = { text: value }; + break; default: - return { identifier: core }; + query = { identifier: core }; + break; } + + for (let i = 1; i < segments.length; i++) { + const segment = segments[i] as string; + const segColonIndex = segment.indexOf(':'); + if (segColonIndex < 0) { + continue; + } + const key = segment.slice(0, segColonIndex); + const val = segment.slice(segColonIndex + 1); + if (key === 'type') { + query.type = val; + } + } + + return query; } /** diff --git a/vitest.config.mts b/vitest.config.mts index 655840f..13b3e86 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -37,8 +37,8 @@ export default defineConfig({ autoUpdate: !process.env.CI, branches: 89.5, functions: 93.02, - lines: 95.64, - statements: 95.36, + lines: 95.66, + statements: 95.39, }, },