diff --git a/e2e/package.json b/e2e/package.json index 1f04828..ddfd5f4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,8 +5,8 @@ "description": "End-to-end tests for mobilewright drivers", "type": "module", "scripts": { - "test:mobilecli": "MOBILEWRIGHT_DRIVER=mobilecli npx mobilewright test", - "test:mobilenext": "MOBILEWRIGHT_DRIVER=mobilenext npx mobilewright test" + "test:mobilecli": "MOBILEWRIGHT_DRIVER=mobilecli node ../packages/mobilewright/dist/cli.js test", + "test:mobilenext": "MOBILEWRIGHT_DRIVER=mobilenext node ../packages/mobilewright/dist/cli.js test" }, "dependencies": { "mobilewright": "^0.0.1", diff --git a/package-lock.json b/package-lock.json index 136d6aa..4f1232d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2091,9 +2091,9 @@ } }, "node_modules/mobilecli": { - "version": "0.3.75", - "resolved": "https://registry.npmjs.org/mobilecli/-/mobilecli-0.3.75.tgz", - "integrity": "sha512-hSJRgkr5dESiNLQeDxfL9g6C5VGW/3f7DkqjF3U4I9Gi2YYXpViyaAMlAL7sp+ytnpBSNvqLXnp0EkxSaehi+A==", + "version": "0.3.78", + "resolved": "https://registry.npmjs.org/mobilecli/-/mobilecli-0.3.78.tgz", + "integrity": "sha512-xmJrbK9OuhGpU/Zhj8MNVens0vn3og5BdFeDkdfSnth2Wt1fDNjLPIaSTFB4IIe1pXYhjy1UbShU+u4cmneBww==", "license": "MIT", "bin": { "mobilecli": "index.js" @@ -2538,7 +2538,7 @@ "dependencies": { "@mobilewright/protocol": "^0.0.1", "debug": "^4.4.3", - "mobilecli": "0.3.75", + "mobilecli": "0.3.78", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/driver-mobilecli/package.json b/packages/driver-mobilecli/package.json index 216a924..f4b7645 100644 --- a/packages/driver-mobilecli/package.json +++ b/packages/driver-mobilecli/package.json @@ -31,7 +31,7 @@ "dependencies": { "@mobilewright/protocol": "^0.0.1", "debug": "^4.4.3", - "mobilecli": "0.3.75", + "mobilecli": "0.3.78", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/driver-mobilecli/src/driver.test.ts b/packages/driver-mobilecli/src/driver.test.ts index 3af5e00..43e8014 100644 --- a/packages/driver-mobilecli/src/driver.test.ts +++ b/packages/driver-mobilecli/src/driver.test.ts @@ -72,6 +72,25 @@ function allowRpc(driver: MobilecliDriver): void { (driver as any).session.rpc.call = async () => ({}); } +interface RecordedCall { + method: string; + params: Record; +} + +// Replace the session's RPC transport with a recorder that returns canned +// responses keyed by method name, capturing every (method, params) pair. +function recordRpc( + driver: MobilecliDriver, + responses: Record, +): RecordedCall[] { + const calls: RecordedCall[] = []; + (driver as any).session.rpc.call = async (method: string, params: Record) => { + calls.push({ method, params }); + return responses[method]; + }; + return calls; +} + test.describe('MobilecliDriver.installApp()', () => { test.describe('iOS simulator', () => { test('accepts a .zip file', async () => { @@ -199,3 +218,77 @@ test.describe('MobilecliDriver.installApp()', () => { }); }); }); + +test.describe('MobilecliDriver.webViewBridge', () => { + test('listWebViews maps device.webview.list entries to WebViewInfo', async () => { + const driver = createDriverWithSession(); + recordRpc(driver, { + 'device.webview.list': [ + { + id: 'wv-1', + url: 'https://example.com/', + title: 'Example', + bundleId: 'com.example.app', + bounds: { x: 0, y: 100, width: 390, height: 700 }, + isVisible: true, + }, + ], + }); + + const webviews = await driver.webViewBridge.listWebViews(); + expect(webviews).toEqual([ + { + id: 'wv-1', + url: 'https://example.com/', + title: 'Example', + nativeBounds: { x: 0, y: 100, width: 390, height: 700 }, + }, + ]); + }); + + test('navigation methods call the matching RPC with the device and webview ids', async () => { + const driver = createDriverWithSession(); + const calls = recordRpc(driver, {}); + const session = await driver.webViewBridge.attachWebView('wv-1'); + + await session.goto('https://example.com/'); + await session.goBack(); + await session.goForward(); + await session.reload(); + + expect(calls.map((c) => c.method)).toEqual([ + 'device.webview.goto', + 'device.webview.goBack', + 'device.webview.goForward', + 'device.webview.reload', + ]); + // deviceId is injected by the driver, id by the session. + expect(calls[0].params).toEqual({ deviceId: SIMULATOR_DEVICE_ID, id: 'wv-1', url: 'https://example.com/' }); + expect(calls[1].params).toEqual({ deviceId: SIMULATOR_DEVICE_ID, id: 'wv-1' }); + }); + + test('evaluate forwards the expression and returns the value directly', async () => { + const driver = createDriverWithSession(); + const calls = recordRpc(driver, { 'device.webview.evaluate': 42 }); + const session = await driver.webViewBridge.attachWebView('wv-1'); + + const value = await session.evaluate('6 * 7'); + expect(value).toBe(42); + expect(calls[0]).toEqual({ + method: 'device.webview.evaluate', + params: { deviceId: SIMULATOR_DEVICE_ID, id: 'wv-1', expression: '6 * 7' }, + }); + }); + + test('url and title return the raw RPC string results', async () => { + const driver = createDriverWithSession(); + recordRpc(driver, { + 'device.webview.url': 'https://example.com/page', + 'device.webview.title': 'Page Title', + }); + const session = await driver.webViewBridge.attachWebView('wv-1'); + + expect(await session.url()).toBe('https://example.com/page'); + expect(await session.title()).toBe('Page Title'); + }); +}); diff --git a/packages/driver-mobilecli/src/driver.ts b/packages/driver-mobilecli/src/driver.ts index c3df114..19043e6 100644 --- a/packages/driver-mobilecli/src/driver.ts +++ b/packages/driver-mobilecli/src/driver.ts @@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'; import { openSync, readSync, closeSync } from 'node:fs'; import type { AppInfo, + Bounds, ConnectionConfig, DeviceInfo, DeviceState, @@ -22,6 +23,9 @@ import type { SwipeDirection, SwipeOptions, ViewNode, + WebViewBridge, + WebViewInfo, + WebViewSession, } from '@mobilewright/protocol'; import { RpcClient } from './rpc-client.js'; import { resolveMobilecliBinary } from './resolve-binary.js'; @@ -99,6 +103,20 @@ interface MobilecliAgentStatusResponse { }; } +/** A single embedded webview as returned by device.webview.list */ +interface MobilecliWebViewEntry { + id: string; + url: string; + title: string; + bundleId?: string; + processName?: string; + bounds?: Bounds; + isVisible?: boolean; +} + +/** RPC caller bound to a device, used by the webview session. */ +type WebViewRpcCall = (method: string, params?: Record) => Promise; + const VALID_PLATFORMS = new Set(['ios', 'android']); const VALID_DEVICE_TYPES = new Set(['real', 'simulator', 'emulator']); const VALID_DEVICE_STATES = new Set(['online', 'offline']); @@ -149,6 +167,63 @@ function assertValidZipFile(path: string): void { const debug = createDebug('mw:driver-mobilecli'); +/** + * A WebViewSession backed by mobilecli's device.webview.* RPC methods. + * Bound to a single webview `id`; the deviceId is injected by the caller. + * The core's WebLocator drives the page through evaluate() and the injected + * DOM selector engine. + */ +class MobilecliWebViewSession implements WebViewSession { + constructor( + private readonly call: WebViewRpcCall, + private readonly id: string, + ) {} + + async evaluate(expr: string): Promise { + // mobilecli evaluates the expression and returns the value directly + // (not wrapped in a { result } envelope). + return this.call('device.webview.evaluate', { + id: this.id, + expression: expr, + }); + } + + async goto(url: string): Promise { + await this.call('device.webview.goto', { id: this.id, url }); + } + + async goBack(): Promise { + await this.call('device.webview.goBack', { id: this.id }); + } + + async goForward(): Promise { + await this.call('device.webview.goForward', { id: this.id }); + } + + async reload(): Promise { + await this.call('device.webview.reload', { id: this.id }); + } + + async url(): Promise { + return this.call('device.webview.url', { id: this.id }); + } + + async title(): Promise { + return this.call('device.webview.title', { id: this.id }); + } + + async waitForLoadState(state?: 'load' | 'domcontentloaded'): Promise { + await this.call('device.webview.waitForLoadState', { + id: this.id, + ...(state !== undefined && { state }), + }); + } + + async close(): Promise { + // mobilecli has no webview-close RPC; webviews are owned by the host app. + } +} + export class MobilecliDriver implements MobilewrightDriver { private session: { deviceId: string; deviceName: string; platform: Platform; deviceType: DeviceType; rpc: RpcClient } | null = null; private readonly serverUrl: string; @@ -491,6 +566,26 @@ export class MobilecliDriver implements MobilewrightDriver { await this.call('device.url', { url }); } + // ─── WebView ───────────────────────────────────────────────── + + get webViewBridge(): WebViewBridge { + const call: WebViewRpcCall = (method, params) => this.call(method, params); + return { + listWebViews: async (): Promise => { + const entries = await this.call('device.webview.list'); + return entries.map((e) => ({ + id: e.id, + url: e.url, + title: e.title, + ...(e.bounds && { nativeBounds: e.bounds }), + })); + }, + attachWebView: async (id: string): Promise => { + return new MobilecliWebViewSession(call, id); + }, + }; + } + // ─── Helpers ───────────────────────────────────────────────── /** RPC call on the active session, auto-injecting deviceId. */ diff --git a/packages/mobilewright-core/src/dom-selector-engine.test.ts b/packages/mobilewright-core/src/dom-selector-engine.test.ts new file mode 100644 index 0000000..7fcd6d8 --- /dev/null +++ b/packages/mobilewright-core/src/dom-selector-engine.test.ts @@ -0,0 +1,25 @@ +import { test, expect as playwrightExpect } from '@playwright/test'; +import { DOM_SELECTOR_ENGINE } from './dom-selector-engine.js'; + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +// The selector engine is injected into a real browser as a string, so it is not +// executed here — behavioural coverage lives in the e2e webview tests. These +// guards ensure the stateful-regex resets are not dropped: a /g or /y RegExp +// advances lastIndex on every .test(), so the engine must reset it before each +// call, otherwise repeated matching across elements silently skips matches. +test.describe('DOM_SELECTOR_ENGINE stateful-regex resets', () => { + test('resets lastIndex before every RegExp .test() (text, attribute, role matchers)', () => { + playwrightExpect(countOccurrences(DOM_SELECTOR_ENGINE, 'lastIndex = 0')).toBeGreaterThanOrEqual(3); + }); + + test('resets the RegExp in buildTextMatcher and findByAttr', () => { + playwrightExpect(DOM_SELECTOR_ENGINE).toContain('textOrRegex.lastIndex = 0'); + }); + + test('resets the RegExp before testing the accessible name in findByRole', () => { + playwrightExpect(DOM_SELECTOR_ENGINE).toContain('name.lastIndex = 0'); + }); +}); diff --git a/packages/mobilewright-core/src/dom-selector-engine.ts b/packages/mobilewright-core/src/dom-selector-engine.ts new file mode 100644 index 0000000..2d45b5e --- /dev/null +++ b/packages/mobilewright-core/src/dom-selector-engine.ts @@ -0,0 +1,463 @@ +// Browser-injectable selector engine injected once per Page via session.evaluate(). +// Defines window.__mw with Playwright-compatible DOM matching helpers. +// Written from scratch against the W3C accName spec and HTML-AAM spec, +// matching Playwright's observable behavior for the common cases. + +export const DOM_SELECTOR_ENGINE = ` +(function () { + // ─── Text helpers ──────────────────────────────────────────── + + function normalizeWhiteSpace(value) { + return value.replace(/[\\u200b\\u00ad]/g, '').replace(/\\s+/g, ' ').trim(); + } + + function shouldSkipForTextMatching(el) { + const nodeName = el.nodeName; + return nodeName === 'SCRIPT' || nodeName === 'NOSCRIPT' || nodeName === 'STYLE' || nodeName === 'TEMPLATE' || + (el.ownerDocument.head && el.ownerDocument.head.contains(el)); + } + + // Returns the full text content of an element, recursing into children but + // skipping script/style nodes. Matches Playwright's elementText().full. + function elementFullText(el) { + if (shouldSkipForTextMatching(el)) { + return ''; + } + if ((el.nodeName === 'INPUT') && (el.type === 'submit' || el.type === 'button')) { + return el.value; + } + let text = ''; + for (let child = el.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 3 /* TEXT_NODE */) { + text += child.nodeValue || ''; + } else if (child.nodeType === 1 /* ELEMENT_NODE */) { + text += elementFullText(child); + } + } + return text; + } + + function elementNormalizedText(el) { + return normalizeWhiteSpace(elementFullText(el)); + } + + // Mirrors Playwright's elementMatchesText(): returns whether the element's + // text matches AND whether a child also matches (to find the innermost match). + // Returns 'none' | 'self' | 'selfAndChildren'. + function elementMatchesText(el, matcher) { + if (shouldSkipForTextMatching(el)) { + return 'none'; + } + if (!matcher(elementNormalizedText(el))) { + return 'none'; + } + for (let child = el.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && matcher(elementNormalizedText(child))) { + return 'selfAndChildren'; + } + } + return 'self'; + } + + // Iterates all elements in document order (like querySelectorAll('*')), + // returning only the innermost matching elements — mirrors Playwright's + // internal:text engine behavior. + function findByText(root, textOrRegex, exact) { + const matcher = buildTextMatcher(textOrRegex, exact); + const result = []; + const all = root.querySelectorAll('*'); + for (const el of all) { + const match = elementMatchesText(el, matcher); + if (match === 'self') { + result.push(el); + } + // 'selfAndChildren' → skip the parent, child will be pushed when visited + } + return result; + } + + function buildTextMatcher(textOrRegex, exact) { + if (textOrRegex instanceof RegExp) { + return text => { + textOrRegex.lastIndex = 0; + return textOrRegex.test(text); + }; + } + if (exact) { + return text => text === textOrRegex; + } + // Playwright default: case-sensitive substring on normalised text + return text => text.includes(textOrRegex); + } + + // ─── Attribute helpers ─────────────────────────────────────── + + // Used by getByPlaceholder, getByAltText, getByTitle. + // Playwright default (exact=false): case-insensitive substring. + function findByAttr(root, attrName, textOrRegex, exact) { + const elements = Array.from(root.querySelectorAll('[' + attrName + ']')); + return elements.filter(el => { + const value = el.getAttribute(attrName) || ''; + if (textOrRegex instanceof RegExp) { + textOrRegex.lastIndex = 0; + return textOrRegex.test(value); + } + if (exact) { + return value === textOrRegex; + } + return value.toLowerCase().includes(textOrRegex.toLowerCase()); + }); + } + + // ─── ARIA role computation ──────────────────────────────────── + + const kAncestorPreventingLandmark = 'article,aside,main,nav,section'; + + const kInputTypeToRole = { + button: 'button', checkbox: 'checkbox', image: 'button', + number: 'spinbutton', radio: 'radio', range: 'slider', + reset: 'button', submit: 'button', + }; + + // https://w3c.github.io/html-aam/#html-element-role-mappings + const kImplicitRole = { + A: el => el.hasAttribute('href') ? 'link' : null, + AREA: el => el.hasAttribute('href') ? 'link' : null, + ARTICLE: () => 'article', + ASIDE: () => 'complementary', + BLOCKQUOTE: () => 'blockquote', + BUTTON: () => 'button', + CAPTION: () => 'caption', + CODE: () => 'code', + DATALIST: () => 'listbox', + DD: () => 'definition', + DEL: () => 'deletion', + DETAILS: () => 'group', + DFN: () => 'term', + DIALOG: () => 'dialog', + DT: () => 'term', + EM: () => 'emphasis', + FIELDSET: () => 'group', + FIGURE: () => 'figure', + FOOTER: el => el.closest(kAncestorPreventingLandmark) ? null : 'contentinfo', + FORM: el => (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) ? 'form' : null, + H1: () => 'heading', H2: () => 'heading', H3: () => 'heading', + H4: () => 'heading', H5: () => 'heading', H6: () => 'heading', + HEADER: el => el.closest(kAncestorPreventingLandmark) ? null : 'banner', + HR: () => 'separator', + HTML: () => 'document', + IMG: el => (el.getAttribute('alt') === '' && !el.getAttribute('title') && !el.hasAttribute('tabindex')) ? 'presentation' : 'img', + INPUT: el => { + const type = (el.type || '').toLowerCase(); + if (type === 'search') { + return el.hasAttribute('list') ? 'combobox' : 'searchbox'; + } + if (['email','tel','text','url',''].includes(type)) { + return el.hasAttribute('list') ? 'combobox' : 'textbox'; + } + if (type === 'hidden') { + return null; + } + if (type === 'file') { + return 'button'; + } + return kInputTypeToRole[type] || 'textbox'; + }, + INS: () => 'insertion', + LI: () => 'listitem', + MAIN: () => 'main', + MARK: () => 'mark', + MATH: () => 'math', + MENU: () => 'list', + METER: () => 'meter', + NAV: () => 'navigation', + OL: () => 'list', + OPTGROUP: () => 'group', + OPTION: () => 'option', + OUTPUT: () => 'status', + P: () => 'paragraph', + PROGRESS: () => 'progressbar', + SEARCH: () => 'search', + SECTION: el => (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) ? 'region' : null, + SELECT: el => (el.hasAttribute('multiple') || el.size > 1) ? 'listbox' : 'combobox', + STRONG: () => 'strong', + SUB: () => 'subscript', + SUP: () => 'superscript', + SVG: () => 'img', + TABLE: () => 'table', + TBODY: () => 'rowgroup', + TD: el => { + const tableEl = el.closest('table'); + const tableRole = tableEl ? getExplicitRole(tableEl) : null; + return (tableRole === 'grid' || tableRole === 'treegrid') ? 'gridcell' : 'cell'; + }, + TEXTAREA: () => 'textbox', + TFOOT: () => 'rowgroup', + TH: el => { + const scope = el.getAttribute('scope'); + return (scope === 'row' || scope === 'rowgroup') ? 'rowheader' : 'columnheader'; + }, + THEAD: () => 'rowgroup', + TIME: () => 'time', + TR: () => 'row', + UL: () => 'list', + }; + + const kValidRoles = new Set(['alert','alertdialog','application','article','banner','blockquote','button','caption','cell','checkbox','code','columnheader','combobox','complementary','contentinfo','definition','deletion','dialog','directory','document','emphasis','feed','figure','form','generic','grid','gridcell','group','heading','img','insertion','link','list','listbox','listitem','log','main','mark','marquee','math','meter','menu','menubar','menuitem','menuitemcheckbox','menuitemradio','navigation','none','note','option','paragraph','presentation','progressbar','radio','radiogroup','region','row','rowgroup','rowheader','scrollbar','search','searchbox','separator','slider','spinbutton','status','strong','subscript','superscript','switch','tab','table','tablist','tabpanel','term','textbox','time','timer','toolbar','tooltip','tree','treegrid','treeitem']); + + function getExplicitRole(el) { + const tokens = (el.getAttribute('role') || '').split(/\\s+/).map(role => role.trim()); + return tokens.find(role => kValidRoles.has(role)) || null; + } + + function getImplicitRole(el) { + const fn = kImplicitRole[el.tagName]; + return fn ? fn(el) : null; + } + + function isNativelyFocusable(el) { + const tag = el.tagName; + if (['BUTTON','DETAILS','SELECT','TEXTAREA'].includes(tag)) { + return true; + } + if (tag === 'A' || tag === 'AREA') { + return el.hasAttribute('href'); + } + if (tag === 'INPUT') { + return (el.type || '').toLowerCase() !== 'hidden'; + } + return false; + } + + function getAriaRole(el) { + const explicit = getExplicitRole(el); + if (!explicit) { + return getImplicitRole(el); + } + // Presentation conflict resolution: explicit none/presentation is overridden + // when element is focusable or has global ARIA attributes. + if (explicit === 'none' || explicit === 'presentation') { + if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') || + !isNaN(Number(el.getAttribute('tabindex'))) || isNativelyFocusable(el)) { + return getImplicitRole(el) || explicit; + } + } + return explicit; + } + + // ─── Accessible name (W3C accName, simplified) ─────────────── + + function getIdRefs(el, attrValue) { + if (!attrValue) { + return []; + } + const root = el.getRootNode() || document; + return attrValue.split(/\\s+/).filter(Boolean).flatMap(id => { + try { + const found = (root.querySelector ? root : document).querySelector('#' + CSS.escape(id)); + return found ? [found] : []; + } catch (e) { + return []; + } + }); + } + + // Priority: aria-labelledby > aria-label > native label/alt/placeholder/title > content + function getAccessibleName(el, visited) { + if (!visited) { + visited = new Set(); + } + if (visited.has(el)) { + return ''; + } + visited.add(el); + + // 1. aria-labelledby + const labelledByRefs = getIdRefs(el, el.getAttribute('aria-labelledby')); + if (labelledByRefs.length) { + return normalizeWhiteSpace(labelledByRefs.map(ref => getAccessibleName(ref, visited)).join(' ')); + } + + // 2. aria-label + const ariaLabel = (el.getAttribute('aria-label') || '').trim(); + if (ariaLabel) { + return ariaLabel; + } + + const tag = el.tagName; + + // 3. input[type=button/submit/reset] + if (tag === 'INPUT') { + const type = (el.type || '').toLowerCase(); + if (['button','submit','reset'].includes(type)) { + const val = (el.value || '').trim(); + if (val) { + return val; + } + if (type === 'submit') { + return 'Submit'; + } + if (type === 'reset') { + return 'Reset'; + } + return el.getAttribute('title') || ''; + } + if (type === 'image') { + const alt = (el.getAttribute('alt') || '').trim(); + return alt || el.getAttribute('title') || 'Submit'; + } + } + + // 4. Associated