|
| 1 | +/** |
| 2 | + * mobilewright inspect |
| 3 | + * |
| 4 | + * Dumps the live accessibility tree from a connected device to the terminal. |
| 5 | + * Useful for figuring out which locator to use when writing tests — run it |
| 6 | + * with the app open on the screen you want to inspect. |
| 7 | + * |
| 8 | + * Pass --json to get raw ViewNode[] JSON suitable for piping to jq or saving |
| 9 | + * to a file for diffing between two app states. |
| 10 | + */ |
| 11 | +import { MobilecliDriver, DEFAULT_URL } from '@mobilewright/driver-mobilecli'; |
| 12 | +import type { ViewNode } from '@mobilewright/protocol'; |
| 13 | +import { ensureMobilecliReachable } from '../server.js'; |
| 14 | +import { loadConfig } from '../config.js'; |
| 15 | + |
| 16 | +// ─── ANSI helpers ───────────────────────────────────────────────────────────── |
| 17 | + |
| 18 | +const C = { |
| 19 | + reset: '\x1b[0m', |
| 20 | + bold: '\x1b[1m', |
| 21 | + dim: '\x1b[2m', |
| 22 | + cyan: '\x1b[36m', |
| 23 | + green: '\x1b[32m', |
| 24 | + gray: '\x1b[90m', |
| 25 | + white: '\x1b[97m', |
| 26 | +} as const; |
| 27 | + |
| 28 | +// ─── Tree renderer ──────────────────────────────────────────────────────────── |
| 29 | + |
| 30 | +function renderNode(node: ViewNode, prefix: string, isLast: boolean): void { |
| 31 | + const connector = isLast ? '└── ' : '├── '; |
| 32 | + const childPrefix = prefix + (isLast ? ' ' : '│ '); |
| 33 | + |
| 34 | + let line = `${C.gray}${prefix}${connector}${C.reset}`; |
| 35 | + line += `${C.cyan}${node.type}${C.reset}`; |
| 36 | + |
| 37 | + if (node.label) line += ` ${C.white}label=${JSON.stringify(node.label)}${C.reset}`; |
| 38 | + if (node.text && node.text !== node.label) line += ` ${C.white}"${node.text}"${C.reset}`; |
| 39 | + if (node.placeholder) line += ` ${C.dim}placeholder=${JSON.stringify(node.placeholder)}${C.reset}`; |
| 40 | + if (node.identifier) line += ` ${C.dim}id=${JSON.stringify(node.identifier)}${C.reset}`; |
| 41 | + |
| 42 | + line += node.isVisible |
| 43 | + ? ` ${C.green}[visible]${C.reset}` |
| 44 | + : ` ${C.gray}[hidden]${C.reset}`; |
| 45 | + |
| 46 | + process.stdout.write(line + '\n'); |
| 47 | + |
| 48 | + const children = node.children ?? []; |
| 49 | + for (let i = 0; i < children.length; i++) { |
| 50 | + renderNode(children[i], childPrefix, i === children.length - 1); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +function renderTree(nodes: ViewNode[]): void { |
| 55 | + for (let i = 0; i < nodes.length; i++) { |
| 56 | + renderNode(nodes[i], '', i === nodes.length - 1); |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +function countNodes(nodes: ViewNode[]): number { |
| 61 | + return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children ?? []), 0); |
| 62 | +} |
| 63 | + |
| 64 | +// ─── Device resolution ──────────────────────────────────────────────────────── |
| 65 | + |
| 66 | +async function resolveDeviceId(explicit: string | undefined, driver: MobilecliDriver): Promise<string> { |
| 67 | + if (explicit) return explicit; |
| 68 | + |
| 69 | + const config = await loadConfig(); |
| 70 | + if (config.deviceId) return config.deviceId; |
| 71 | + |
| 72 | + const devices = await driver.listDevices(); |
| 73 | + const online = devices.filter(d => d.state === 'online'); |
| 74 | + |
| 75 | + if (online.length === 0) { |
| 76 | + console.error('No online devices found. Specify one with --device <id>, or try \'mobilewright doctor\'.'); |
| 77 | + process.exit(1); |
| 78 | + } |
| 79 | + |
| 80 | + if (config.deviceName) { |
| 81 | + const pattern = config.deviceName instanceof RegExp |
| 82 | + ? config.deviceName |
| 83 | + : new RegExp(config.deviceName); |
| 84 | + const matched = online.filter(d => pattern.test(d.name)); |
| 85 | + if (matched.length > 0) return matched[0].id; |
| 86 | + } |
| 87 | + |
| 88 | + if (online.length > 1) { |
| 89 | + console.error('Multiple devices found. Specify one with --device <id>:'); |
| 90 | + for (const d of online) console.error(` ${d.id} ${d.name}`); |
| 91 | + process.exit(1); |
| 92 | + } |
| 93 | + |
| 94 | + return online[0].id; |
| 95 | +} |
| 96 | + |
| 97 | +// ─── Main ───────────────────────────────────────────────────────────────────── |
| 98 | + |
| 99 | +export interface InspectOptions { |
| 100 | + device?: string; |
| 101 | + url?: string; |
| 102 | + json?: boolean; |
| 103 | +} |
| 104 | + |
| 105 | +export async function runInspect(opts: InspectOptions): Promise<void> { |
| 106 | + const config = await loadConfig(); |
| 107 | + const url = opts.url ?? DEFAULT_URL; |
| 108 | + const platform = config.platform ?? 'android'; |
| 109 | + |
| 110 | + const { serverProcess } = await ensureMobilecliReachable(url, { autoStart: true }); |
| 111 | + try { |
| 112 | + const driver = new MobilecliDriver({ url }); |
| 113 | + const deviceId = await resolveDeviceId(opts.device, driver); |
| 114 | + |
| 115 | + await driver.connect({ platform, deviceId, url }); |
| 116 | + const tree = await driver.getViewHierarchy(); |
| 117 | + await driver.disconnect(); |
| 118 | + |
| 119 | + if (opts.json) { |
| 120 | + console.log(JSON.stringify(tree, null, 2)); |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + const total = countNodes(tree); |
| 125 | + const label = config.deviceName?.toString().replace(/^\/|\/$/g, '') ?? deviceId; |
| 126 | + console.log(`\n${C.bold}${label}${C.reset} ${C.dim}${total} nodes${C.reset}\n`); |
| 127 | + renderTree(tree); |
| 128 | + console.log(); |
| 129 | + } finally { |
| 130 | + if (serverProcess) await serverProcess.kill(); |
| 131 | + } |
| 132 | +} |
0 commit comments