diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c6ae2..2df7425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The mobile a11y snapshot no longer computes the ancestor-role `path` on Android, emitting an empty `path` for every node instead. Android `path` values were widget class names (e.g. `android.widget.FrameLayout`) that dominated the `describe-screen` payload with no targeting value (mobile targeting is by a11y `ref` or `testId`). iOS and browser behavior are unchanged. ([#44](https://github.com/MetaMask/client-mcp-core/pull/44)) + ## [0.8.0] ### Added diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index abda0e4..3e4b9d9 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -509,6 +509,42 @@ describe('MobilePlatformDriver', () => { expect(nodes[2].path).toStrictEqual(['Window']); }); + it('emits an empty path for every node on android', async () => { + const backend = createMockBackend({ + platform: 'android', + snapshot: vi.fn().mockResolvedValue({ + platform: 'android', + hierarchy: [ + makeElement({ + type: 'android.widget.FrameLayout', + children: [ + makeElement({ + type: 'android.widget.LinearLayout', + children: [ + makeElement({ type: 'android.widget.Button', label: 'OK' }), + makeElement({ + type: 'android.widget.Button', + label: 'Cancel', + }), + ], + }), + ], + }), + ], + raw: '[]', + timestamp: Date.now(), + }), + }); + const driver = new MobilePlatformDriver(backend); + + const { nodes } = await driver.getAccessibilityTree(); + + expect(nodes).toHaveLength(4); + for (const node of nodes) { + expect(node.path).toStrictEqual([]); + } + }); + it('builds refMap with value fallback', async () => { const backend = createMockBackend({ snapshot: vi.fn().mockResolvedValue({ diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 7f9ca65..bf418df 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -193,7 +193,7 @@ export class MobilePlatformDriver implements IPlatformDriver { _rootSelector?: string, ): Promise<{ nodes: A11yNodeTrimmed[]; refMap: Map }> { const snapshot = await this.#backend.snapshot(); - return normalizeSnapshot(snapshot.hierarchy); + return normalizeSnapshot(snapshot.hierarchy, this.#backend.platform); } /** @@ -685,21 +685,33 @@ function parseStableIdentifier(stableId: string): ElementQuery { /** * Normalizes a UIElement hierarchy into A11yNodeTrimmed nodes with sequential refs. * + * On Android the ancestor-role `path` is a chain of widget class names + * (e.g. `android.widget.FrameLayout`) that offers no targeting value — mobile + * targeting is by a11y `ref` or `testId` only — and dominates the payload + * returned to agents. So on Android we emit an empty `path` rather than + * accumulating the ancestry. iOS keeps the ancestor path (its parity is + * unvalidated), matching the browser driver's behavior. + * * @param hierarchy - The raw UIElement tree from a device snapshot. + * @param platform - The device platform; `'android'` skips path computation. * @returns Trimmed nodes and the ref-to-stable-identifier map. */ -function normalizeSnapshot(hierarchy: UIElement[]): { +function normalizeSnapshot( + hierarchy: UIElement[], + platform: PlatformType, +): { nodes: A11yNodeTrimmed[]; refMap: Map; } { const nodes: A11yNodeTrimmed[] = []; const refMap = new Map(); const stableIdCount = new Map(); + const skipPath = platform === 'android'; let refCounter = 0; /** * @param elements - UIElement nodes to walk. - * @param path - Accumulated ancestor roles. + * @param path - Accumulated ancestor roles (always empty on Android). */ function walk(elements: UIElement[], path: string[]): void { for (const el of elements) { @@ -737,7 +749,7 @@ function normalizeSnapshot(hierarchy: UIElement[]): { } if (el.children?.length) { - walk(el.children, [...path, role]); + walk(el.children, skipPath ? path : [...path, role]); } } }