Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/mobilewright/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -248,6 +249,17 @@ program
}
});

// ── inspect ───────────────────────────────────────────────────────
program
.command('inspect')
.description('dump the live accessibility tree of a connected device')
.option('-d, --device <id>', 'device ID (run "mobilewright devices" to list)')
.option('--url <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')
Expand Down
132 changes: 132 additions & 0 deletions packages/mobilewright/src/commands/inspect.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 <id>, 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 <id>:');
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<void> {
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();
}
}
2 changes: 2 additions & 0 deletions packages/mobilewright/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
19 changes: 18 additions & 1 deletion packages/test/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type MobilewrightTestFixtures = {
bundleId: string | undefined;
platform: 'ios' | 'android' | undefined;
deviceName: RegExp | undefined;
saveTreeOnFailure: boolean;
device: Device;
};

Expand All @@ -53,6 +54,11 @@ export const test = base.extend<MobilewrightTestFixtures>({
platform: [undefined, { option: true }],
deviceName: [undefined, { option: true }],

saveTreeOnFailure: [async ({}, use) => {
const config = await loadConfig();
await use(config.saveTreeOnFailure ?? false);
}, { option: true }],

device: async ({ platform, deviceName, bundleId }, use) => {
const config = await loadConfig();
const merged = {
Expand Down Expand Up @@ -104,7 +110,7 @@ export const test = base.extend<MobilewrightTestFixtures>({
}
},

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
Expand Down Expand Up @@ -145,6 +151,17 @@ export const test = base.extend<MobilewrightTestFixtures>({
} 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
}
}
}
},
});
Expand Down