Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/proof/inspect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Device proof: nativeproof inspect (issue #23, step 2)

`android-settings-inspect.log` is the verbatim output of `nativeproof inspect --android`
run 2026-07-02 against a live Android 15 emulator showing the Settings home screen:
41 candidate locators — semantic roles with accessible names first, then every visible
text, then resource-id test ids — with no page-source XML in sight.
48 changes: 47 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
type AppiumOptions,
findConfigFile,
type NativeProofConfig,
projectCapabilities,
type RunnerEnv,
resolveProject,
} from "./config.js";
import { selectorSuggestions } from "./inspect.js";

/**
* The `nativeproof` CLI — the single-command entry, in the spirit of `playwright test`.
Expand All @@ -32,7 +34,7 @@ import {
*/

export interface CliArgs {
command: "test" | "init" | "onboard" | "help" | "version";
command: "test" | "init" | "onboard" | "inspect" | "help" | "version";
platform: "android" | "ios" | undefined;
initPlatform: "android" | "ios" | undefined;
onboardPath: string | undefined;
Expand Down Expand Up @@ -96,6 +98,10 @@ export function parseArgs(
args.command = "onboard";
continue;
}
if (arg === "inspect") {
args.command = "inspect";
continue;
}
if (arg === "-h" || arg === "--help") return { ...args, command: "help" };
if (arg === "-v" || arg === "--version") return { ...args, command: "version" };
if (arg === "--ios") {
Expand Down Expand Up @@ -145,6 +151,7 @@ export function helpText(): string {
" nativeproof init --ios scaffold nativeproof.config.ts + a sample spec for iOS",
" nativeproof init --android scaffold nativeproof.config.ts + a sample spec for Android",
" nativeproof onboard <path> point nativeproof.config.ts at an app artifact or iOS project",
" nativeproof inspect launch the configured app and print candidate locators",
" nativeproof-init --ios same init shortcut, useful from package-manager bins",
" nativeproof-init --android same init shortcut for Android",
" nativeproof-onboard <path> onboard shortcut, useful from package-manager bins",
Expand Down Expand Up @@ -1144,6 +1151,42 @@ export function runSelection(args: CliArgs, env: NodeJS.ProcessEnv = process.env
return selection;
}

/**
* `nativeproof inspect` — selector discovery (issue #23, step 2): start a session with the
* configured project (noReset so app state survives), dump the first screen's candidate
* locators, and tear the session down. Kills the read-the-XML-and-guess authoring loop.
*/
async function runInspect(args: CliArgs): Promise<number> {
const { configPath } = resolveRunner(args);
const userConfig = await loadNativeProofConfig(configPath);
const project = resolveProject(userConfig, runSelection(args));
const appium = await ensureAppium(userConfig.appium, args.startAppium, project.platform);
try {
const { remote } = await import("webdriverio");
const endpoint = appiumEndpoint(userConfig.appium);
const session = await remote({
hostname: endpoint.host,
port: endpoint.port,
path: endpoint.path,
logLevel: "warn",
capabilities: { ...projectCapabilities(userConfig, project), "appium:noReset": true },
});
try {
const source = await session.getPageSource();
const suggestions = selectorSuggestions(source, project.platform);
console.log(
`nativeproof inspect — ${suggestions.length} candidate locators on the current ${project.platform} screen\n`,
);
for (const suggestion of suggestions) console.log(` ${suggestion}`);
} finally {
await session.deleteSession().catch(() => {});
}
return 0;
} finally {
appium?.kill();
}
}

async function runTests(args: CliArgs): Promise<number> {
const { wdioConfig, configPath, extraEnv } = resolveRunner(args);
const userConfig = await loadNativeProofConfig(configPath);
Expand Down Expand Up @@ -1192,6 +1235,9 @@ export async function main(
}
return onboardCommand(process.cwd(), args.onboardPath, { platform: args.platform ?? undefined });
}
if (args.command === "inspect") {
return runInspect(args);
}
return runTests(args);
}

Expand Down
17 changes: 10 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ function resolveSpecs(config: RunnerConfig, project: DeviceProject, env: RunnerE
return [abs(`${testDir}/${testMatch}`)];
}

/** The full session capabilities for a project: platform defaults, host device, then the project's own. */
export function projectCapabilities(config: RunnerConfig, project: DeviceProject): Record<string, unknown> {
return {
...defaultCapabilities(project.platform),
...hostDeviceDefaults(config, project),
...project.capabilities,
};
}

/**
* Translate an NativeProof config into a WebdriverIO `config` object.
*/
Expand All @@ -235,13 +244,7 @@ export function buildWdioConfig(
path: config.appium?.path ?? "/wd/hub",
specs: resolveSpecs(config, project, env, cwd),
maxInstances: 1,
capabilities: [
{
...defaultCapabilities(project.platform),
...hostDeviceDefaults(config, project),
...project.capabilities,
},
],
capabilities: [projectCapabilities(config, project)],
framework: "mocha",
reporters: ["spec"],
mochaOpts: { ui: "bdd", timeout: config.mochaTimeout ?? 240_000 },
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from "./expect.js";
export * from "./fixtures.js";
export * from "./gestures.js";
export * from "./harness.js";
export * from "./inspect.js";
export * from "./ios.js";
export * from "./locator.js";
export * from "./log.js";
Expand Down
60 changes: 60 additions & 0 deletions src/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Platform } from "./driver.js";
import { decodeXmlEntities, KNOWN_ROLES, nodesForRole } from "./source.js";

/**
* Selector discovery for `nativeproof inspect`: turn a live page source into the
* candidate locators a spec author would write, most semantic first — the authoring-time
* counterpart of the did-you-mean failure hint, so nobody reads XML and guesses strings.
*/

const MAX_VALUE_LENGTH = 60;

function attributeValues(node: string, attribute: string): string[] {
// The lookbehind keeps whole attribute names: `value=` must not read `placeholderValue=`.
return [...node.matchAll(new RegExp(`(?<![\\w-])${attribute}="([^"]*)"`, "g"))]
.map((match) => decodeXmlEntities(match[1] ?? "").trim())
.filter((value) => value !== "" && value.length <= MAX_VALUE_LENGTH);
}

/**
* Candidate `native.*` locators for everything the current screen exposes, in the order a
* reader should prefer them: semantic roles (with accessible names), then visible text —
* exactly the alternation `getByText` matches — then test ids. Deduplicated verbatim.
*/
export function selectorSuggestions(source: string, platform: Platform): string[] {
const suggestions = new Set<string>();

for (const role of KNOWN_ROLES) {
for (const node of nodesForRole(source, role, platform)) {
const [name] = [
...attributeValues(node, platform === "ios" ? "label" : "content-desc"),
...attributeValues(node, platform === "ios" ? "value" : "text"),
];
suggestions.add(
name
? `native.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(name)} })`
: `native.getByRole(${JSON.stringify(role)})`,
);
}
}

const nodes = [...source.matchAll(/<[^>]*>/g)].map((match) => match[0]);
const textAttributes = platform === "ios" ? ["label", "value"] : ["text", "content-desc"];
for (const node of nodes) {
for (const attribute of textAttributes) {
for (const text of attributeValues(node, attribute)) {
suggestions.add(`native.getByText(${JSON.stringify(text)})`);
}
}
}
for (const node of nodes) {
const labels = new Set(textAttributes.flatMap((attribute) => attributeValues(node, attribute)));
for (const id of attributeValues(node, platform === "ios" ? "name" : "resource-id")) {
// An iOS `name` that just mirrors the label adds nothing over getByText.
if (platform === "ios" && labels.has(id)) continue;
suggestions.add(`native.getByTestId(${JSON.stringify(id)})`);
}
}

return [...suggestions];
}
5 changes: 5 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,3 +659,8 @@ test("runSelection falls back to the env vars the runner itself reads", () => {
assert.deepEqual(runSelection(args, { NATIVEPROOF_PROJECT: "beta" }), { project: "beta" });
assert.deepEqual(runSelection(parseArgs(["--android"]), { PLATFORM: "ios" }), { platform: "android" });
});

test("parseArgs recognises the inspect command and helpText documents it", () => {
assert.equal(parseArgs(["inspect", "--android"]).command, "inspect");
assert.match(helpText(), /nativeproof inspect\s+launch the configured app and print candidate locators/);
});
42 changes: 42 additions & 0 deletions test/inspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { selectorSuggestions } from "../src/inspect.js";

/** Selector discovery over realistic page-source shapes — pure, no device. */

test("android sources suggest roles first, then visible text, then test ids", () => {
const source =
'<node class="android.widget.CheckBox" content-desc="Accept terms" checked="false" bounds="[0,0][40,40]" />' +
'<node class="android.widget.TextView" text="Welcome back" bounds="[0,60][200,100]" />' +
'<node class="android.widget.EditText" text="" resource-id="com.app:id/email" bounds="[0,120][200,160]" />';
const suggestions = selectorSuggestions(source, "android");
assert.deepEqual(suggestions, [
'native.getByRole("checkbox", { name: "Accept terms" })',
'native.getByRole("textfield")',
'native.getByText("Accept terms")',
'native.getByText("Welcome back")',
'native.getByTestId("com.app:id/email")',
]);
});

test("ios sources read values, skip placeholders, and drop names that mirror labels", () => {
const source =
'<XCUIElementTypeButton type="XCUIElementTypeButton" name="Log in" label="Log in" x="0" y="0" width="100" height="40" />' +
'<XCUIElementTypeTextField type="XCUIElementTypeTextField" name="session-field" label="" placeholderValue="Session ID" value="abc123" x="0" y="50" width="100" height="40" />';
const suggestions = selectorSuggestions(source, "ios");
assert.deepEqual(suggestions, [
'native.getByRole("button", { name: "Log in" })',
'native.getByRole("textfield", { name: "abc123" })',
'native.getByText("Log in")',
'native.getByText("abc123")',
'native.getByTestId("session-field")',
]);
});

test("suggestions skip empty and over-long values and deduplicate", () => {
const long = "x".repeat(80);
const source =
`<node text="${long}" bounds="[0,0][10,10]" />` +
'<node text="Save" bounds="[0,0][10,10]" /><node text="Save" bounds="[0,20][10,30]" /><node text="" />';
assert.deepEqual(selectorSuggestions(source, "android"), ['native.getByText("Save")']);
});