diff --git a/docs/proof/inspect/README.md b/docs/proof/inspect/README.md new file mode 100644 index 0000000..be70ee5 --- /dev/null +++ b/docs/proof/inspect/README.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index 82d9d69..bd3d486 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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`. @@ -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; @@ -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") { @@ -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 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 onboard shortcut, useful from package-manager bins", @@ -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 { + 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 { const { wdioConfig, configPath, extraEnv } = resolveRunner(args); const userConfig = await loadNativeProofConfig(configPath); @@ -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); } diff --git a/src/config.ts b/src/config.ts index 06001b7..a1a8fb9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { + return { + ...defaultCapabilities(project.platform), + ...hostDeviceDefaults(config, project), + ...project.capabilities, + }; +} + /** * Translate an NativeProof config into a WebdriverIO `config` object. */ @@ -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 }, diff --git a/src/index.ts b/src/index.ts index 604ecfb..498bc1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/inspect.ts b/src/inspect.ts new file mode 100644 index 0000000..e5549d1 --- /dev/null +++ b/src/inspect.ts @@ -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(`(? 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(); + + 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]; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 9430c36..78a6716 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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/); +}); diff --git a/test/inspect.test.ts b/test/inspect.test.ts new file mode 100644 index 0000000..3e19d6a --- /dev/null +++ b/test/inspect.test.ts @@ -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 = + '' + + '' + + ''; + 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 = + '' + + ''; + 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 = + `` + + ''; + assert.deepEqual(selectorSuggestions(source, "android"), ['native.getByText("Save")']); +});