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(); + } +} 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 + } + } } }, });