From b13d9e4518a2c4124340fa3343d6d7e80395830b Mon Sep 17 00:00:00 2001 From: ring-wdr Date: Thu, 26 Mar 2026 16:01:01 +0900 Subject: [PATCH] Add integration harness and click targeting for 0.1.35-rc.3 --- README.md | 6 +- docs/devtools-concept-mapping.md | 1 + docs/workflows.md | 4 +- package-lock.json | 4 +- package.json | 6 +- scripts/run-integration-harness.mjs | 501 ++++++++++++++++++ .../references/repo-workflows.md | 23 +- src/cli.js | 90 ++-- src/server.js | 107 +++- test/run-tests.js | 39 ++ 10 files changed, 736 insertions(+), 45 deletions(-) create mode 100644 scripts/run-integration-harness.mjs diff --git a/README.md b/README.md index 1feae88..0bf384c 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,14 @@ rdt profiler export --session demo --compress - Use built-in `interact` commands before reaching for external Playwright helper scripts. - Current supported actions: - - `rdt interact click --session --selector [--delivery auto|playwright|dom]` + - `rdt interact click --session (--selector | --text | --role ) [--nth ] [--strict] [--delivery auto|playwright|dom]` - `rdt interact type --session --selector --text ` - `rdt interact press --session --key [--selector ]` - `rdt interact wait --session --ms ` - These commands execute through the same Playwright session that owns the current `rdt` browser page. -- They target the first matching selector only and return structured action metadata plus trust-boundary fields. +- `interact click` can resolve targets by CSS selector, visible text, or ARIA role. +- `--nth` selects one match from a broader result set, and `--strict` requires exactly one match. +- Responses now include `targetingStrategy`, `matchCount`, and `resolvedNth` alongside the delivery metadata. - `interact click` defaults to `--delivery auto`. - In `auto`, profiler-active clicks fall back to DOM dispatch and report `requestedDelivery`, `effectiveDelivery`, `profilerActive`, and `fallbackApplied`. - Use `--delivery playwright` to force Playwright pointer input, or `--delivery dom` to force DOM dispatch. diff --git a/docs/devtools-concept-mapping.md b/docs/devtools-concept-mapping.md index c517992..1849881 100644 --- a/docs/devtools-concept-mapping.md +++ b/docs/devtools-concept-mapping.md @@ -46,6 +46,7 @@ It is a maintenance aid for agents and contributors. It is not a commitment to r | `profiler compare` | `src/cli.js` | No direct public equivalent | CLI-side comparison of stored profiler artifacts or exported NDJSON files. | | `interact click|type|press|wait` | `src/server.js` | No direct public equivalent | Playwright-backed deterministic interaction helpers for agent workflows. | | `interact click --delivery` | `src/server.js` | No direct public equivalent | CLI-specific interaction contract for choosing Playwright pointer input vs DOM dispatch. | +| `interact click --selector|--text|--role` | `src/server.js` | No direct public equivalent | Locator targeting helpers that resolve a single actionable match for CLI workflows. | | `session doctor` | `src/server.js` + `src/runtime-script.js` | No direct public equivalent | CLI-specific preflight that reports trust boundaries, runtime readiness, Playwright resolution diagnostics, and helper import targets. | ## Intentional Divergences diff --git a/docs/workflows.md b/docs/workflows.md index 1aeb785..868fc5c 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -78,13 +78,15 @@ Use it to confirm: Built-in interactions keep the investigation inside the same session instead of forcing separate helper scripts. ```bash -rdt interact click --session demo --selector 'button.save' --delivery auto +rdt interact click --session demo --role button --nth 0 --delivery auto rdt interact type --session demo --selector 'input[name="query"]' --text hello rdt interact wait --session demo --ms 500 ``` - `interact click --delivery auto` uses Playwright pointer input by default. - When the profiler is active, `auto` may fall back to DOM dispatch and reports the applied delivery in the response payload. +- Use one targeting mode per click: `--selector`, `--text`, or `--role`. +- Add `--nth` to choose one match from a broader result set, or `--strict` to require exactly one match. After interaction, verify the app settled by collecting a fresh tree or reading profiler output instead of assuming the UI state changed correctly. diff --git a/package-lock.json b/package-lock.json index d289d03..07b6639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-devtool-cli", - "version": "0.1.35-rc.2", + "version": "0.1.35-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-devtool-cli", - "version": "0.1.35-rc.2", + "version": "0.1.35-rc.3", "license": "MIT", "dependencies": { "playwright": "1.58.2" diff --git a/package.json b/package.json index 4752368..a29bb7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-devtool-cli", - "version": "0.1.35-rc.2", + "version": "0.1.35-rc.3", "description": "Agent-first CLI for React component tree inspection, snapshot-aware node debugging, and profiler analysis through a Playwright-managed browser session.", "license": "MIT", "type": "module", @@ -37,7 +37,9 @@ "docs:dev": "vitepress dev docs", "docs:preview": "vitepress preview docs --port 4173", "prepack": "npm run build", - "test": "node test/run-tests.js" + "test": "node test/run-tests.js", + "test:integration": "node scripts/run-integration-harness.mjs", + "test:integration:verbose": "node scripts/run-integration-harness.mjs --verbose" }, "engines": { "node": ">=22.0.0" diff --git a/scripts/run-integration-harness.mjs b/scripts/run-integration-harness.mjs new file mode 100644 index 0000000..3b0cd4d --- /dev/null +++ b/scripts/run-integration-harness.mjs @@ -0,0 +1,501 @@ +import fs from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import process from "node:process"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const testAppDir = path.join(rootDir, "test-app"); +const viteBinPath = path.join(testAppDir, "node_modules", "vite", "bin", "vite.js"); +const verbose = process.argv.includes("--verbose"); + +function log(line) { + process.stdout.write(`${line}\n`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getNpmExecutable() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +async function pathExists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function ensureTestAppDependencies() { + if (await pathExists(path.join(testAppDir, "node_modules"))) { + return; + } + + log("info - installing test-app dependencies with npm ci"); + const result = await runCommand(getNpmExecutable(), ["ci"], { + cwd: testAppDir, + timeoutMs: 180000, + }); + ensureCommandOk("test-app npm ci", result); +} + +async function findAvailablePort(startPort = 4310, maxAttempts = 20) { + for (let offset = 0; offset < maxAttempts; offset += 1) { + const port = startPort + offset; + if (port === 3000) { + continue; + } + + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.on("error", () => resolve(false)); + server.listen({ host: "127.0.0.1", port }, () => { + server.close(() => resolve(true)); + }); + }); + + if (available) { + return port; + } + } + + throw new Error(`Unable to find an available port starting from ${startPort}.`); +} + +function terminateProcessTree(pid) { + if (!pid) { + return; + } + + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } +} + +async function runCommand(command, args, options = {}) { + const { + cwd = rootDir, + env = process.env, + timeoutMs = 60000, + } = options; + + return await new Promise((resolve) => { + const child = spawn(command, args, { + cwd, + env, + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + terminateProcessTree(child.pid); + resolve({ + command, + args, + cwd, + code: null, + signal: "SIGKILL", + stdout, + stderr, + timedOut: true, + }); + }, timeoutMs); + + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + if (verbose) { + process.stdout.write(text); + } + }); + + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + if (verbose) { + process.stderr.write(text); + } + }); + + child.on("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + command, + args, + cwd, + code: null, + signal: null, + stdout, + stderr: `${stderr}${error.stack || error.message || String(error)}`, + timedOut: false, + }); + }); + + child.on("close", (code, signal) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + command, + args, + cwd, + code, + signal, + stdout, + stderr, + timedOut: false, + }); + }); + }); +} + +function formatCommand(result) { + return `${result.command} ${result.args.join(" ")}`.trim(); +} + +function ensure(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function ensureCommandOk(label, result) { + if (result.code === 0 && !result.timedOut) { + return; + } + + throw new Error( + `${label} failed\ncommand: ${formatCommand(result)}\ncode: ${result.code}\nsignal: ${result.signal}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); +} + +function parseJsonResult(label, result) { + ensureCommandOk(label, result); + ensure(result.stdout.trim(), `${label} returned empty stdout\ncommand: ${formatCommand(result)}\nstderr:\n${result.stderr}`); + + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `${label} returned non-JSON stdout\ncommand: ${formatCommand(result)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\nparse error: ${error.message}`, + ); + } +} + +async function waitForHttpReady(url, child, logs, timeoutMs = 30000) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (child.exitCode != null) { + throw new Error(`dev server exited early\nstdout:\n${logs.stdout}\nstderr:\n${logs.stderr}`); + } + + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch {} + + await sleep(250); + } + + throw new Error(`Timed out waiting for ${url}\nstdout:\n${logs.stdout}\nstderr:\n${logs.stderr}`); +} + +async function startDevServer(port) { + const child = spawn(process.execPath, [viteBinPath, "--host", "127.0.0.1", "--port", String(port)], { + cwd: testAppDir, + windowsHide: true, + }); + + const logs = { stdout: "", stderr: "" }; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + logs.stdout += text; + if (verbose) { + process.stdout.write(text); + } + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + logs.stderr += text; + if (verbose) { + process.stderr.write(text); + } + }); + + const url = `http://127.0.0.1:${port}`; + await waitForHttpReady(url, child, logs); + return { child, url, logs }; +} + +async function stopDevServer(server) { + if (!server?.child?.pid) { + return; + } + + terminateProcessTree(server.child.pid); + await sleep(500); +} + +async function runRdt(args, timeoutMs = 60000) { + return await runCommand(process.execPath, [path.join("bin", "rdt.js"), ...args], { + cwd: rootDir, + timeoutMs, + }); +} + +function logScenarioOk(name) { + log(`ok - ${name}`); +} + +async function closeSession(sessionName) { + const result = await runRdt(["session", "close", "--session", sessionName, "--format", "json"], 30000); + if (result.code !== 0) { + log(`warn - session close failed for ${sessionName}\n${result.stderr}`); + } +} + +async function main() { + await ensureTestAppDependencies(); + + const port = await findAvailablePort(4310, 30); + const server = await startDevServer(port); + const sessionName = `harness-${Date.now().toString(36)}`; + + try { + const openResult = await runRdt([ + "session", + "open", + "--url", + server.url, + "--session", + sessionName, + "--timeout", + "10000", + "--format", + "json", + ], 120000); + const openPayload = parseJsonResult("session-open-json", openResult); + ensure(openPayload.sessionName === sessionName, "session-open-json returned an unexpected sessionName"); + ensure(typeof openPayload.target === "string" && openPayload.target.startsWith(server.url), "session-open-json returned an unexpected target"); + ensure(typeof openPayload.reactDetected === "boolean", "session-open-json did not report reactDetected"); + logScenarioOk("session-open-json"); + + const doctorPayload = parseJsonResult( + "doctor-alias", + await runRdt(["doctor", "--session", sessionName, "--format", "json"]), + ); + ensure(doctorPayload.sessionName === sessionName, "doctor alias returned an unexpected sessionName"); + + const statsPayload = parseJsonResult( + "tree-stats", + await runRdt(["tree", "stats", "--session", sessionName, "--top", "5", "--format", "json"]), + ); + ensure(typeof statsPayload.snapshotId === "string" && statsPayload.snapshotId.length > 0, "tree-stats did not return snapshotId"); + ensure(Array.isArray(statsPayload.rootSummaries), "tree-stats did not return rootSummaries"); + ensure(Array.isArray(statsPayload.topLevelComponents), "tree-stats did not return topLevelComponents"); + + const searchPayload = parseJsonResult( + "node-search-structured", + await runRdt([ + "node", + "search", + "App", + "--session", + sessionName, + "--snapshot", + statsPayload.snapshotId, + "--structured", + "--format", + "json", + ]), + ); + ensure(Array.isArray(searchPayload.items) && searchPayload.matchCount >= 1, "node-search-structured did not return any App matches"); + + const zeroMatchPayload = parseJsonResult( + "node-search-zero-match", + await runRdt([ + "node", + "search", + "__MissingHarnessComponent__", + "--session", + sessionName, + "--snapshot", + statsPayload.snapshotId, + "--structured", + "--format", + "json", + ]), + ); + ensure(zeroMatchPayload.matchCount === 0, "node-search-zero-match did not return matchCount 0"); + ensure(Array.isArray(zeroMatchPayload.runtimeWarnings) && zeroMatchPayload.runtimeWarnings.length > 0, "node-search-zero-match did not return runtimeWarnings"); + logScenarioOk("tree-stats-and-structured-search"); + + const appNodeId = searchPayload.items[0]?.id; + ensure(appNodeId, "node-search-structured did not produce a node id"); + + const rawSourceResult = await runRdt([ + "source", + "reveal", + appNodeId, + "--session", + sessionName, + "--snapshot", + statsPayload.snapshotId, + "--format", + "json", + ]); + ensureCommandOk("source-reveal-raw", rawSourceResult); + ensure(rawSourceResult.stdout.trim() === "null", `source-reveal-raw expected null stdout but received:\n${rawSourceResult.stdout}`); + + const structuredSourcePayload = parseJsonResult( + "source-reveal-structured", + await runRdt([ + "source", + "reveal", + appNodeId, + "--session", + sessionName, + "--snapshot", + statsPayload.snapshotId, + "--structured", + "--format", + "json", + ]), + ); + ensure(structuredSourcePayload.status === "unavailable", "source-reveal-structured did not report unavailable status"); + ensure(structuredSourcePayload.available === false, "source-reveal-structured did not report available=false"); + ensure(typeof structuredSourcePayload.mode === "string", "source-reveal-structured did not return mode"); + ensure(typeof structuredSourcePayload.reason === "string", "source-reveal-structured did not return reason"); + logScenarioOk("structured-source-reveal"); + + const strictSelectorClick = parseJsonResult( + "click-selector-strict", + await runRdt([ + "interact", + "click", + "--session", + sessionName, + "--selector", + "button.counter", + "--strict", + "--delivery", + "playwright", + "--format", + "json", + ]), + ); + ensure(strictSelectorClick.targetingStrategy === "selector", "click-selector-strict did not use selector targeting"); + ensure(strictSelectorClick.strict === true, "click-selector-strict did not report strict=true"); + ensure(strictSelectorClick.matchCount === 1, "click-selector-strict did not report matchCount 1"); + ensure(strictSelectorClick.effectiveDelivery === "playwright", "click-selector-strict did not use playwright delivery"); + + const textClick = parseJsonResult( + "click-text-targeting", + await runRdt([ + "interact", + "click", + "--session", + sessionName, + "--text", + "Count is", + "--delivery", + "playwright", + "--format", + "json", + ]), + ); + ensure(textClick.targetingStrategy === "text", "click-text-targeting did not use text targeting"); + ensure(textClick.effectiveDelivery === "playwright", "click-text-targeting did not use playwright delivery"); + + const roleClick = parseJsonResult( + "click-role-targeting", + await runRdt([ + "interact", + "click", + "--session", + sessionName, + "--role", + "button", + "--nth", + "0", + "--delivery", + "dom", + "--format", + "json", + ]), + ); + ensure(roleClick.targetingStrategy === "role", "click-role-targeting did not use role targeting"); + ensure(roleClick.resolvedNth === 0, "click-role-targeting did not report resolvedNth 0"); + ensure(roleClick.effectiveDelivery === "dom-click", "click-role-targeting did not use DOM delivery"); + + parseJsonResult( + "profiler-start", + await runRdt(["profiler", "start", "--session", sessionName, "--format", "json"]), + ); + const autoFallbackClick = parseJsonResult( + "click-delivery-auto-profiler", + await runRdt([ + "interact", + "click", + "--session", + sessionName, + "--selector", + "button.counter", + "--delivery", + "auto", + "--format", + "json", + ]), + ); + ensure(autoFallbackClick.profilerActive === true, "click-delivery-auto-profiler did not detect active profiler"); + ensure(autoFallbackClick.fallbackApplied === true, "click-delivery-auto-profiler did not report fallbackApplied=true"); + ensure(autoFallbackClick.effectiveDelivery === "dom-click", "click-delivery-auto-profiler did not fall back to dom-click"); + parseJsonResult( + "profiler-stop", + await runRdt(["profiler", "stop", "--session", sessionName, "--format", "json"]), + ); + logScenarioOk("click-targeting-and-delivery"); + } finally { + await closeSession(sessionName); + await stopDevServer(server); + } +} + +main().catch((error) => { + process.stderr.write(`not ok - integration-harness\n${error.stack || error.message || String(error)}\n`); + process.exit(1); +}); diff --git a/skills/react-devtool-cli-repo/references/repo-workflows.md b/skills/react-devtool-cli-repo/references/repo-workflows.md index fafbb18..43b11a6 100644 --- a/skills/react-devtool-cli-repo/references/repo-workflows.md +++ b/skills/react-devtool-cli-repo/references/repo-workflows.md @@ -11,6 +11,7 @@ - Run repository commands from the repo root. - Prefer `node bin/rdt.js ...` over a globally installed `rdt`. - Use `npm test` as the default regression check. +- Use `npm run test:integration` for the minimal browser-backed harness. - Use `npm run build` before packaging checks. ## Browser Validation Target @@ -20,13 +21,13 @@ ```bash cd test-app -npm run dev -- --host 127.0.0.1 --port 3000 +npm run dev -- --host 127.0.0.1 --port 4310 ``` - Open a local inspection session from the repo root with: ```bash -node bin/rdt.js session open --url http://127.0.0.1:3000 --session app --timeout 10000 +node bin/rdt.js session open --url http://127.0.0.1:4310 --session app --timeout 10000 ``` - Advanced session open options: @@ -78,10 +79,26 @@ Default timeout is 30 seconds. Returns the picked node details. ### Interaction - Prefer built-in `interact click`, `interact type`, `interact press`, and `interact wait`. -- Keep selectors deterministic and CSS-based. +- `interact click` can target by `--selector`, `--text`, or `--role`. +- Use `--nth` for broader match sets, or `--strict` when exactly one match is expected. - Run `session doctor` first to confirm `supportsBuiltInInteract`. - All interact commands accept optional `--timeout-ms ` for element location timeout. +### Minimal integration harness + +- Run the repo-owned browser harness with: + +```bash +npm run test:integration +``` + +- The harness intentionally avoids port `3000`. +- It currently validates: + - `session open --format json` stdout integrity + - `tree stats` plus structured search output + - structured source reveal availability reporting + - click targeting and delivery behavior + ### Profiler - Treat profiler output as commit-oriented analysis, not full DevTools frontend state. diff --git a/src/cli.js b/src/cli.js index 9dbfd33..0f98f6b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -96,14 +96,45 @@ function normalizeStructuredFlag(options) { return Boolean(options.structured); } +function collectClickTargetingPayload(options) { + const targetingKeys = ["selector", "text", "role"].filter((key) => options[key] !== undefined && options[key] !== null); + ensure(targetingKeys.length > 0, "Missing click target. Use one of --selector, --text, or --role for `rdt interact click`.", { + code: "missing-click-target", + }); + ensure(targetingKeys.length === 1, "Use exactly one of --selector, --text, or --role for `rdt interact click`.", { + code: "conflicting-click-target", + }); + ensure(!(options.strict && options.nth !== undefined), "Do not combine --strict with --nth for `rdt interact click`.", { + code: "invalid-click-targeting", + }); + + return { + selector: options.selector ? String(options.selector) : undefined, + text: options.text !== undefined ? String(options.text) : undefined, + role: options.role ? String(options.role) : undefined, + nth: options.nth !== undefined ? Number(options.nth) : undefined, + strict: Boolean(options.strict), + }; +} + function resolveCommitId(positionals, options, message) { const commitId = positionals[0] ? String(positionals[0]) : (options.commit ? String(options.commit) : null); ensure(commitId, message, { code: "missing-commit-id" }); return commitId; } -function writeStdout(value, format) { - process.stdout.write(formatOutput(value, format)); +async function writeStdout(value, format) { + const output = formatOutput(value, format); + await new Promise((resolve, reject) => { + process.stdout.write(output, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); } function isMissingProcessMessage(message) { @@ -222,21 +253,21 @@ async function handleSessionCommand(command, options) { if (command === "status") { ensure(options.session, "Missing required option --session", { code: "missing-session" }); const response = await requestSession(options.session, "session.status"); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "doctor") { ensure(options.session, "Missing required option --session", { code: "missing-session" }); const response = await requestSession(options.session, "session.doctor"); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "close") { ensure(options.session, "Missing required option --session", { code: "missing-session" }); const result = await closeSessionWithFallback(String(options.session)); - writeStdout(result, resolveFormat(options)); + await writeStdout(result, resolveFormat(options)); return; } @@ -250,7 +281,7 @@ async function handleSessionCommand(command, options) { url: options.url, }); const status = await waitForSessionReady(sessionName); - writeStdout(status, resolveFormat(options)); + await writeStdout(status, resolveFormat(options)); return; } @@ -262,7 +293,7 @@ async function handleSessionCommand(command, options) { wsEndpoint: options.wsEndpoint, }); const status = await waitForSessionReady(sessionName); - writeStdout(status, resolveFormat(options)); + await writeStdout(status, resolveFormat(options)); return; } @@ -274,7 +305,7 @@ async function handleSessionCommand(command, options) { cdpUrl: options.cdpUrl, }); const status = await waitForSessionReady(sessionName); - writeStdout(status, resolveFormat(options)); + await writeStdout(status, resolveFormat(options)); return; } @@ -290,7 +321,7 @@ async function handleTreeCommand(command, options) { const response = await requestSession(options.session, action, { top: options.top ? Number(options.top) : undefined, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); } async function handleNodeCommand(command, positionals, options) { @@ -304,7 +335,7 @@ async function handleNodeCommand(command, positionals, options) { commitId: options.commit ? String(options.commit) : undefined, ...collectSnapshotPayload(options), }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -316,7 +347,7 @@ async function handleNodeCommand(command, positionals, options) { structured: normalizeStructuredFlag(options), ...collectSnapshotPayload(options), }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -327,7 +358,7 @@ async function handleNodeCommand(command, positionals, options) { nodeId, ...collectSnapshotPayload(options), }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -335,7 +366,7 @@ async function handleNodeCommand(command, positionals, options) { const response = await requestSession(options.session, "node.pick", { timeoutMs: options.timeoutMs ?? 30000, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -346,13 +377,12 @@ async function handleInteractCommand(command, options) { ensure(options.session, "Missing required option --session", { code: "missing-session" }); if (command === "click") { - ensure(options.selector, "Missing required option --selector for `rdt interact click`.", { code: "missing-selector" }); const response = await requestSession(options.session, "interact.click", { - selector: String(options.selector), + ...collectClickTargetingPayload(options), delivery: options.delivery ? String(options.delivery) : undefined, timeoutMs: options.timeoutMs ?? undefined, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -364,7 +394,7 @@ async function handleInteractCommand(command, options) { text: String(options.text), timeoutMs: options.timeoutMs ?? undefined, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -375,7 +405,7 @@ async function handleInteractCommand(command, options) { selector: options.selector ? String(options.selector) : undefined, timeoutMs: options.timeoutMs ?? undefined, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -384,7 +414,7 @@ async function handleInteractCommand(command, options) { const response = await requestSession(options.session, "interact.wait", { ms: Number(options.ms), }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -658,32 +688,32 @@ async function handleProfilerCommand(command, positionals, options) { if (command === "start") { const profileId = options.profileId ? String(options.profileId) : `profile-${Date.now().toString(36)}`; const response = await requestSession(options.session, "profiler.start", { profileId }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "stop") { const response = await requestSession(options.session, "profiler.stop"); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "summary") { const response = await requestSession(options.session, "profiler.summary"); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "commits") { const response = await requestSession(options.session, "profiler.commits"); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } if (command === "commit") { const commitId = resolveCommitId(positionals, options, "Missing required option --commit for `rdt profiler commit`."); const response = await requestSession(options.session, "profiler.commit", { commitId }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -693,7 +723,7 @@ async function handleProfilerCommand(command, positionals, options) { commitId, limit: options.limit ? Number(options.limit) : undefined, }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); return; } @@ -706,7 +736,7 @@ async function handleProfilerCommand(command, positionals, options) { }); } const response = await requestSession(options.session, "profiler.flamegraph", { commitId }); - writeStdout(response.result, format); + await writeStdout(response.result, format); return; } @@ -715,7 +745,7 @@ async function handleProfilerCommand(command, positionals, options) { ensure(options.right, "Missing required option --right for `rdt profiler compare`.", { code: "missing-right-profile" }); const left = await loadProfilerArtifact(options.session, String(options.left)); const right = await loadProfilerArtifact(options.session, String(options.right)); - writeStdout(compareProfiles(left, right), resolveFormat(options)); + await writeStdout(compareProfiles(left, right), resolveFormat(options)); return; } @@ -729,7 +759,7 @@ async function handleProfilerCommand(command, positionals, options) { const response = await requestSession(options.session, "profiler.export"); const result = await writeProfilerExport(options.session, response.result, options); - writeStdout(result, format === "pretty" ? "pretty" : "json"); + await writeStdout(result, format === "pretty" ? "pretty" : "json"); return; } @@ -746,7 +776,7 @@ async function handleSourceCommand(command, positionals, options) { structured: normalizeStructuredFlag(options), ...collectSnapshotPayload(options), }); - writeStdout(response.result, resolveFormat(options)); + await writeStdout(response.result, resolveFormat(options)); } export function normalizeCliPositionals(positionals) { @@ -792,7 +822,7 @@ Usage: rdt node search --session [--snapshot ] [--structured] rdt node highlight --session [--snapshot ] rdt node pick --session [--timeout-ms 30000] - rdt interact click --session --selector [--delivery auto|playwright|dom] [--timeout-ms ] + rdt interact click --session (--selector | --text | --role ) [--nth ] [--strict] [--delivery auto|playwright|dom] [--timeout-ms ] rdt interact type --session --selector --text [--timeout-ms ] rdt interact press --session --key [--selector ] [--timeout-ms ] rdt interact wait --session --ms diff --git a/src/server.js b/src/server.js index 9b92d11..9738ec6 100644 --- a/src/server.js +++ b/src/server.js @@ -222,6 +222,21 @@ function normalizeClickDeliveryMode(value) { }); } +function normalizeClickNth(value) { + if (value == null) { + return 0; + } + + const numeric = Number(value); + if (Number.isInteger(numeric) && numeric >= 0) { + return numeric; + } + + throw new CliError(`Invalid interact click nth index: ${value}`, { + code: "invalid-click-nth", + }); +} + function parseServerArgv(argv) { const options = {}; @@ -697,6 +712,63 @@ class SessionServer { }; } + buildClickLocator(page, payload) { + if (payload.selector) { + return { + locator: page.locator(String(payload.selector)), + targetingStrategy: "selector", + }; + } + + if (payload.text !== undefined) { + return { + locator: page.getByText(String(payload.text)), + targetingStrategy: "text", + }; + } + + if (payload.role) { + return { + locator: page.getByRole(String(payload.role)), + targetingStrategy: "role", + }; + } + + throw new CliError("Missing interact click target.", { + code: "missing-click-target", + }); + } + + async resolveClickTarget(page, payload, timeoutMs) { + const { locator, targetingStrategy } = this.buildClickLocator(page, payload); + await locator.first().waitFor({ state: "attached", timeout: timeoutMs }); + + const matchCount = await locator.count(); + const strict = Boolean(payload.strict); + if (strict && matchCount !== 1) { + throw new CliError( + `Strict interact click expected exactly one match for ${targetingStrategy}, but found ${matchCount}.`, + { code: "interact-click-strict-mismatch" }, + ); + } + + const resolvedNth = strict ? 0 : normalizeClickNth(payload.nth); + if (resolvedNth >= matchCount) { + throw new CliError( + `Interact click match index ${resolvedNth} is out of range for ${targetingStrategy} with ${matchCount} matches.`, + { code: "interact-click-index-out-of-range" }, + ); + } + + return { + locator: locator.nth(resolvedNth), + targetingStrategy, + matchCount, + resolvedNth, + strict, + }; + } + async interact(command, payload) { const page = await this.ensureInteractivePage(); const timeoutMs = payload.timeoutMs ? Number(payload.timeoutMs) : this.timeoutMs; @@ -715,11 +787,26 @@ class SessionServer { } const selector = payload.selector ? String(payload.selector) : null; - const locator = selector ? page.locator(selector).first() : null; - if (locator) { + const text = payload.text !== undefined ? String(payload.text) : null; + const role = payload.role ? String(payload.role) : null; + let locator = selector ? page.locator(selector).first() : null; + let targetingStrategy = selector ? "selector" : null; + let matchCount = locator ? 1 : null; + let resolvedNth = null; + let strict = false; + + if (command === "click") { + const clickTarget = await this.resolveClickTarget(page, payload, timeoutMs); + locator = clickTarget.locator; + targetingStrategy = clickTarget.targetingStrategy; + matchCount = clickTarget.matchCount; + resolvedNth = clickTarget.resolvedNth; + strict = clickTarget.strict; + } else if (locator) { await locator.waitFor({ state: "attached", timeout: timeoutMs }); } - const target = selector + + const target = locator ? await locator.evaluate((element) => ({ tagName: element.tagName.toLowerCase(), id: element.id || null, @@ -764,9 +851,13 @@ class SessionServer { runtimeWarnings.push("Click delivery was forced to DOM dispatch even though profiler fallback was not required."); } + const limitations = command === "click" + ? ["interact click resolves a single locator match; without --strict, broader match sets may still require caller verification"] + : ["selector-based interaction targets the first matching element only"]; + return { observationLevel: "observed", - limitations: ["selector-based interaction targets the first matching element only"], + limitations, runtimeWarnings, action: command, ok: true, @@ -775,10 +866,16 @@ class SessionServer { effectiveDelivery: delivery, profilerActive, fallbackApplied, + targetingStrategy, + matchCount, + resolvedNth, + strict, selector, + textQuery: text, + role, target, key: payload.key ? String(payload.key) : null, - textLength: payload.text != null ? String(payload.text).length : null, + textLength: command === "type" && payload.text != null ? String(payload.text).length : null, }; } diff --git a/test/run-tests.js b/test/run-tests.js index 69123d7..76fc195 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -138,6 +138,7 @@ run("parseArgv preserves interact click options", () => { ".result-row", "--delivery", "dom", + "--strict", "--timeout-ms", "1200", ]); @@ -146,9 +147,47 @@ run("parseArgv preserves interact click options", () => { assert.equal(parsed.options.session, "app"); assert.equal(parsed.options.selector, ".result-row"); assert.equal(parsed.options.delivery, "dom"); + assert.equal(parsed.options.strict, true); assert.equal(parsed.options.timeoutMs, 1200); }); +run("parseArgv preserves interact click text and nth options", () => { + const parsed = parseArgv([ + "interact", + "click", + "--session", + "app", + "--text", + "Count is", + "--nth", + "2", + "--delivery", + "playwright", + ]); + + assert.deepEqual(parsed.positionals, ["interact", "click"]); + assert.equal(parsed.options.text, "Count is"); + assert.equal(parsed.options.nth, 2); + assert.equal(parsed.options.delivery, "playwright"); +}); + +run("parseArgv preserves interact click role options", () => { + const parsed = parseArgv([ + "interact", + "click", + "--session", + "app", + "--role", + "button", + "--nth", + "0", + ]); + + assert.deepEqual(parsed.positionals, ["interact", "click"]); + assert.equal(parsed.options.role, "button"); + assert.equal(parsed.options.nth, 0); +}); + run("parseArgv preserves tree stats options", () => { const parsed = parseArgv([ "tree",