From 3f45e70031aae06cc8407d00304546499c8a9af0 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 13:06:52 +0600 Subject: [PATCH 1/3] feat(test): attach accessibility tree on test failure via saveTreeOnFailure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots show what the screen looks like when a test fails, but not why a locator didn't match. The accessibility tree carries that context — element labels, types, visibility flags, and bounds — which is exactly what you need to debug a failing getBy* call. Add saveTreeOnFailure: boolean to MobilewrightConfig. When enabled, the screen fixture calls viewTree() after a failure and attaches the result as view-tree-on-failure (JSON) to the HTML report, next to the existing screenshot. Opt-in, defaults to false, no impact on existing projects. --- packages/mobilewright/src/config.ts | 2 ++ packages/test/src/fixtures.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 7c00ced..2640d65 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -69,6 +69,8 @@ export interface MobilewrightConfig { installApps?: string | string[]; /** Automatically launch the app after connecting. Default: true. */ autoAppLaunch?: boolean; + /** Attach the accessibility tree as JSON to the test report on failure. Default: false. */ + saveTreeOnFailure?: boolean; /** mobilecli server URL (use for remote servers). */ url?: string; /** Path to mobilecli binary (if not on PATH). */ diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index 56ded3e..bd4af2c 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -33,6 +33,7 @@ type MobilewrightTestFixtures = { bundleId: string | undefined; platform: 'ios' | 'android' | undefined; deviceName: RegExp | undefined; + saveTreeOnFailure: boolean; device: Device; }; @@ -53,6 +54,11 @@ export const test = base.extend({ platform: [undefined, { option: true }], deviceName: [undefined, { option: true }], + saveTreeOnFailure: [async ({}, use, testInfo) => { + const config = await loadConfig(process.cwd(), testInfo.config.configFile); + await use(config.saveTreeOnFailure ?? false); + }, { option: true }], + device: async ({ platform, deviceName, bundleId }, use, testInfo) => { const config = await loadConfig(process.cwd(), testInfo.config.configFile); const merged = { @@ -104,7 +110,7 @@ export const test = base.extend({ } }, - screen: async ({ device, video }, use, testInfo) => { + screen: async ({ device, video, saveTreeOnFailure }, use, testInfo) => { const videoMode = typeof video === 'object' ? video.mode : video; const shouldRecord = videoMode === 'on' || videoMode === 'retain-on-failure'; const videoPath = shouldRecord @@ -145,6 +151,17 @@ export const test = base.extend({ } catch { // device may be disconnected } + if (saveTreeOnFailure) { + try { + const tree = await device.screen.viewTree(); + await testInfo.attach('view-tree-on-failure', { + body: Buffer.from(JSON.stringify(tree, null, 2)), + contentType: 'application/json', + }); + } catch { + // device may be disconnected + } + } } }, }); From 7d0b6173766a72b8de49e4d74b43a0be23ae96b7 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 13:06:52 +0600 Subject: [PATCH 2/3] feat(cli): add inspect command to dump live accessibility tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When writing tests, the main friction is not knowing the exact label, text, or type of an element on screen. The only option was to add screen.viewTree() inside a test and read the console output — which requires running a test just to inspect the UI. The inspect command solves this directly: run it with the app open on the screen you want to target, and it prints the full accessibility tree in the terminal with element types, labels, text, and visibility — no test needed. npx mobilewright inspect Supports --json to output raw ViewNode[] for piping to jq or saving as a snapshot to diff between two app states: npx mobilewright inspect --json | jq '.[] | select(.label != null)' Respects deviceName from config for automatic device resolution, and accepts -d/--device to target a specific device when multiple are connected. --- packages/mobilewright/src/cli.ts | 12 ++ packages/mobilewright/src/commands/inspect.ts | 132 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/mobilewright/src/commands/inspect.ts diff --git a/packages/mobilewright/src/cli.ts b/packages/mobilewright/src/cli.ts index 41eb6dc..3e74a13 100644 --- a/packages/mobilewright/src/cli.ts +++ b/packages/mobilewright/src/cli.ts @@ -13,6 +13,7 @@ import { MobilecliDriver, DEFAULT_URL, resolveMobilecliBinary } from '@mobilewri import { ensureMobilecliReachable } from './server.js'; import { loadConfig } from './config.js'; import { gatherChecks, renderTerminal, renderJSON } from './commands/doctor.js'; +import { runInspect, type InspectOptions } from './commands/inspect.js'; import { brandReport } from './reporter.js'; import { telemetry } from './telemetry.js'; @@ -248,6 +249,17 @@ program } }); +// ── inspect ─────────────────────────────────────────────────────── +program + .command('inspect') + .description('dump the live accessibility tree of a connected device') + .option('-d, --device ', 'device ID (run "mobilewright devices" to list)') + .option('--url ', 'mobilecli server URL', DEFAULT_URL) + .option('--json', 'output raw ViewNode[] JSON instead of the terminal tree') + .action(async (opts: InspectOptions) => { + await runInspect(opts); + }); + // ── install ─────────────────────────────────────────────────────── program .command('install') diff --git a/packages/mobilewright/src/commands/inspect.ts b/packages/mobilewright/src/commands/inspect.ts new file mode 100644 index 0000000..e39f3a3 --- /dev/null +++ b/packages/mobilewright/src/commands/inspect.ts @@ -0,0 +1,132 @@ +/** + * mobilewright inspect + * + * Dumps the live accessibility tree from a connected device to the terminal. + * Useful for figuring out which locator to use when writing tests — run it + * with the app open on the screen you want to inspect. + * + * Pass --json to get raw ViewNode[] JSON suitable for piping to jq or saving + * to a file for diffing between two app states. + */ +import { MobilecliDriver, DEFAULT_URL } from '@mobilewright/driver-mobilecli'; +import type { ViewNode } from '@mobilewright/protocol'; +import { ensureMobilecliReachable } from '../server.js'; +import { loadConfig } from '../config.js'; + +// ─── ANSI helpers ───────────────────────────────────────────────────────────── + +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + gray: '\x1b[90m', + white: '\x1b[97m', +} as const; + +// ─── Tree renderer ──────────────────────────────────────────────────────────── + +function renderNode(node: ViewNode, prefix: string, isLast: boolean): void { + const connector = isLast ? '└── ' : '├── '; + const childPrefix = prefix + (isLast ? ' ' : '│ '); + + let line = `${C.gray}${prefix}${connector}${C.reset}`; + line += `${C.cyan}${node.type}${C.reset}`; + + if (node.label) line += ` ${C.white}label=${JSON.stringify(node.label)}${C.reset}`; + if (node.text && node.text !== node.label) line += ` ${C.white}"${node.text}"${C.reset}`; + if (node.placeholder) line += ` ${C.dim}placeholder=${JSON.stringify(node.placeholder)}${C.reset}`; + if (node.identifier) line += ` ${C.dim}id=${JSON.stringify(node.identifier)}${C.reset}`; + + line += node.isVisible + ? ` ${C.green}[visible]${C.reset}` + : ` ${C.gray}[hidden]${C.reset}`; + + process.stdout.write(line + '\n'); + + const children = node.children ?? []; + for (let i = 0; i < children.length; i++) { + renderNode(children[i], childPrefix, i === children.length - 1); + } +} + +function renderTree(nodes: ViewNode[]): void { + for (let i = 0; i < nodes.length; i++) { + renderNode(nodes[i], '', i === nodes.length - 1); + } +} + +function countNodes(nodes: ViewNode[]): number { + return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children ?? []), 0); +} + +// ─── Device resolution ──────────────────────────────────────────────────────── + +async function resolveDeviceId(explicit: string | undefined, driver: MobilecliDriver): Promise { + if (explicit) return explicit; + + const config = await loadConfig(); + if (config.deviceId) return config.deviceId; + + const devices = await driver.listDevices(); + const online = devices.filter(d => d.state === 'online'); + + if (online.length === 0) { + console.error('No online devices found. Specify one with --device , or try \'mobilewright doctor\'.'); + process.exit(1); + } + + if (config.deviceName) { + const pattern = config.deviceName instanceof RegExp + ? config.deviceName + : new RegExp(config.deviceName); + const matched = online.filter(d => pattern.test(d.name)); + if (matched.length > 0) return matched[0].id; + } + + if (online.length > 1) { + console.error('Multiple devices found. Specify one with --device :'); + for (const d of online) console.error(` ${d.id} ${d.name}`); + process.exit(1); + } + + return online[0].id; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +export interface InspectOptions { + device?: string; + url?: string; + json?: boolean; +} + +export async function runInspect(opts: InspectOptions): Promise { + const config = await loadConfig(); + const url = opts.url ?? DEFAULT_URL; + const platform = config.platform ?? 'android'; + + const { serverProcess } = await ensureMobilecliReachable(url, { autoStart: true }); + try { + const driver = new MobilecliDriver({ url }); + const deviceId = await resolveDeviceId(opts.device, driver); + + await driver.connect({ platform, deviceId, url }); + const tree = await driver.getViewHierarchy(); + await driver.disconnect(); + + if (opts.json) { + console.log(JSON.stringify(tree, null, 2)); + return; + } + + const total = countNodes(tree); + const label = config.deviceName?.toString().replace(/^\/|\/$/g, '') ?? deviceId; + console.log(`\n${C.bold}${label}${C.reset} ${C.dim}${total} nodes${C.reset}\n`); + renderTree(tree); + console.log(); + } finally { + if (serverProcess) await serverProcess.kill(); + } +} From a1f12c92240be97715e8a2169e2c5afde0b46300 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 13:06:52 +0600 Subject: [PATCH 3/3] feat(cli): add --ui flag to inspect command for browser-based tree explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal tree from inspect is useful for quick lookups but hard to navigate on complex screens with deep hierarchies. When a screen has 100+ nodes, scrolling through a flat terminal dump to find the right element is slow. The --ui flag opens a browser at localhost:9325 with a two-panel UI: the left panel shows the collapsible accessibility tree, the right panel shows the selected node's properties and ready-to-use locator code that can be copied to the clipboard with one click. Auto-refresh polls the device on a configurable interval (1s/2s/5s/10s) so the tree stays live as you navigate the app — no need to re-run the command for each screen. A manual Refresh button is always available when auto-refresh is off. --- packages/mobilewright/src/cli.ts | 10 +- .../mobilewright/src/commands/inspect-ui.ts | 438 ++++++++++++++++++ 2 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 packages/mobilewright/src/commands/inspect-ui.ts diff --git a/packages/mobilewright/src/cli.ts b/packages/mobilewright/src/cli.ts index 3e74a13..339b1b4 100644 --- a/packages/mobilewright/src/cli.ts +++ b/packages/mobilewright/src/cli.ts @@ -14,6 +14,7 @@ import { ensureMobilecliReachable } from './server.js'; import { loadConfig } from './config.js'; import { gatherChecks, renderTerminal, renderJSON } from './commands/doctor.js'; import { runInspect, type InspectOptions } from './commands/inspect.js'; +import { runInspectUI } from './commands/inspect-ui.js'; import { brandReport } from './reporter.js'; import { telemetry } from './telemetry.js'; @@ -256,8 +257,13 @@ program .option('-d, --device ', 'device ID (run "mobilewright devices" to list)') .option('--url ', 'mobilecli server URL', DEFAULT_URL) .option('--json', 'output raw ViewNode[] JSON instead of the terminal tree') - .action(async (opts: InspectOptions) => { - await runInspect(opts); + .option('--ui', 'open an interactive browser UI with auto-refresh and locator copy') + .action(async (opts: InspectOptions & { ui?: boolean }) => { + if (opts.ui) { + await runInspectUI(opts); + } else { + await runInspect(opts); + } }); // ── install ─────────────────────────────────────────────────────── diff --git a/packages/mobilewright/src/commands/inspect-ui.ts b/packages/mobilewright/src/commands/inspect-ui.ts new file mode 100644 index 0000000..f6c2f75 --- /dev/null +++ b/packages/mobilewright/src/commands/inspect-ui.ts @@ -0,0 +1,438 @@ +/** + * mobilewright inspect --ui + * + * Serves an interactive browser UI that renders the live accessibility tree + * from a connected device. Supports manual refresh and auto-refresh with a + * configurable interval. Clicking a node shows its properties and ready-to-use + * locator suggestions with a one-click copy button. + * + * Usage: npx mobilewright inspect --ui + */ +import { createServer } from 'node:http'; +import { exec } from 'node:child_process'; +import { platform as osPlatform } from 'node:os'; +import { MobilecliDriver, DEFAULT_URL } from '@mobilewright/driver-mobilecli'; +import type { MobilewrightConfig } from '../config.js'; +import { ensureMobilecliReachable } from '../server.js'; +import { loadConfig } from '../config.js'; + +const PORT = 9325; + +export interface InspectUIOptions { + device?: string; + url?: string; +} + +// ─── Device resolution ──────────────────────────────────────────────────────── + +async function resolveDeviceId( + explicit: string | undefined, + driver: MobilecliDriver, + config: MobilewrightConfig, +): Promise { + if (explicit) return explicit; + if (config.deviceId) return config.deviceId; + + const devices = await driver.listDevices(); + const online = devices.filter(d => d.state === 'online'); + + if (online.length === 0) { + console.error("No online devices found. Specify one with --device , or try 'mobilewright doctor'."); + process.exit(1); + } + + if (config.deviceName) { + const pattern = config.deviceName instanceof RegExp + ? config.deviceName + : new RegExp(config.deviceName); + const matched = online.filter(d => pattern.test(d.name)); + if (matched.length > 0) return matched[0].id; + } + + if (online.length > 1) { + console.error('Multiple devices found. Specify one with --device :'); + for (const d of online) console.error(` ${d.id} ${d.name}`); + process.exit(1); + } + + return online[0].id; +} + +// ─── Persistent device connection ──────────────────────────────────────────── + +function makeConnection(url: string, platform: 'ios' | 'android', deviceId: string) { + let driver: MobilecliDriver | null = null; + let connected = false; + + async function ensureConnected(): Promise { + if (!connected) { + driver = new MobilecliDriver({ url }); + await driver.connect({ platform, deviceId, url }); + connected = true; + } + } + + async function getTree() { + try { + await ensureConnected(); + return await driver!.getViewHierarchy(); + } catch { + connected = false; + await ensureConnected(); + return await driver!.getViewHierarchy(); + } + } + + async function disconnect(): Promise { + if (driver && connected) { + await driver.disconnect().catch(() => {}); + connected = false; + } + } + + return { getTree, disconnect }; +} + +// ─── Browser open ───────────────────────────────────────────────────────────── + +function openBrowser(url: string): void { + const cmd = osPlatform() === 'win32' ? `start ${url}` + : osPlatform() === 'darwin' ? `open ${url}` + : `xdg-open ${url}`; + exec(cmd); +} + +// ─── HTML ───────────────────────────────────────────────────────────────────── + +const HTML = ` + + + + mobilewright inspect + + + +
+ mobilewright inspect +
+ + + + +
+
+
+
+

Click a node to inspect it

+
+ + +`; + +// ─── Main ───────────────────────────────────────────────────────────────────── + +export async function runInspectUI(opts: InspectUIOptions): Promise { + const config = await loadConfig(); + const url = opts.url ?? DEFAULT_URL; + const platform = (config.platform ?? 'android') as 'ios' | 'android'; + + const { serverProcess } = await ensureMobilecliReachable(url, { autoStart: true }); + + const driver = new MobilecliDriver({ url }); + const deviceId = await resolveDeviceId(opts.device, driver, config); + const conn = makeConnection(url, platform, deviceId); + + const httpServer = createServer(async (req, res) => { + if (req.url === '/api/tree') { + try { + const tree = await conn.getTree(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(tree)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: String(err) })); + } + } else { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(HTML); + } + }); + + httpServer.listen(PORT, () => { + const inspectUrl = `http://localhost:${PORT}`; + console.log(`\nInspector running at ${inspectUrl}\n`); + console.log('Press Ctrl+C to stop.\n'); + openBrowser(inspectUrl); + }); + + process.on('SIGINT', async () => { + httpServer.close(); + await conn.disconnect(); + if (serverProcess) await serverProcess.kill(); + process.exit(0); + }); +}