From 8d2b90c05615ae23c1b2568d5d2e6683c0d3f507 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 13:05:59 +0600 Subject: [PATCH 1/2] 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 70c8495d295e12b7a648e7e670fd1bb3907d3ed1 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 13:05:59 +0600 Subject: [PATCH 2/2] 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(); + } +}