Skip to content

Commit 76ce74e

Browse files
committed
feat(cli): add inspect command to dump live accessibility tree
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.
1 parent dd23065 commit 76ce74e

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

packages/mobilewright/src/cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { MobilecliDriver, DEFAULT_URL, resolveMobilecliBinary } from '@mobilewri
1313
import { ensureMobilecliReachable } from './server.js';
1414
import { loadConfig } from './config.js';
1515
import { gatherChecks, renderTerminal, renderJSON } from './commands/doctor.js';
16+
import { runInspect, type InspectOptions } from './commands/inspect.js';
1617
import { brandReport } from './reporter.js';
1718
import { telemetry } from './telemetry.js';
1819

@@ -248,6 +249,17 @@ program
248249
}
249250
});
250251

252+
// ── inspect ───────────────────────────────────────────────────────
253+
program
254+
.command('inspect')
255+
.description('dump the live accessibility tree of a connected device')
256+
.option('-d, --device <id>', 'device ID (run "mobilewright devices" to list)')
257+
.option('--url <url>', 'mobilecli server URL', DEFAULT_URL)
258+
.option('--json', 'output raw ViewNode[] JSON instead of the terminal tree')
259+
.action(async (opts: InspectOptions) => {
260+
await runInspect(opts);
261+
});
262+
251263
// ── install ───────────────────────────────────────────────────────
252264
program
253265
.command('install')
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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

Comments
 (0)