From 73da14fd2afc7ffab45433ad8eaaf108eac02f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=A7=8C=EC=A4=91?= Date: Thu, 26 Mar 2026 15:25:47 +0900 Subject: [PATCH] Add structured CLI automation outputs and bump to 0.1.35-rc.2 --- README.md | 19 +++- docs/devtools-concept-mapping.md | 6 +- docs/workflows.md | 18 +++- package-lock.json | 4 +- package.json | 2 +- src/cli.js | 46 ++++++-- src/runtime-script.js | 177 ++++++++++++++++++++++++++++++- src/server.js | 104 +++++++++++++++--- test/run-tests.js | 72 ++++++++++++- 9 files changed, 412 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e693558..1feae88 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,15 @@ Published package notes: ## Commands ```bash +rdt doctor --session demo rdt session open --url http://localhost:3000 --browser chromium --engine auto --session demo rdt session connect --ws-endpoint ws://127.0.0.1:3000/ --target-url localhost:3000 --session remote rdt session attach --cdp-url http://127.0.0.1:9222 --target-url localhost:3000 --session cdp rdt session doctor --session demo rdt tree get --session demo +rdt tree stats --session demo --top 5 rdt interact type --session demo --selector 'input[name="query"]' --text hello -rdt node search App --session demo --snapshot +rdt node search App --session demo --snapshot --structured rdt node inspect --session demo --snapshot rdt profiler start --session demo rdt profiler stop --session demo @@ -126,6 +128,7 @@ rdt profiler export --session demo --compress ## Snapshot Semantics - `tree get` returns a `snapshotId`. +- `tree stats` returns the same `snapshotId` plus summary fields such as `rootCount`, `nodeCount`, `rootSummaries`, and `topLevelComponents`. - Node IDs are only meaningful within that snapshot. - The runtime currently keeps up to `5` snapshots in memory per session. - Agent-friendly recommended flow: @@ -139,6 +142,7 @@ rdt profiler export --session demo --compress ## Doctor - `rdt session doctor --session ` reports runtime readiness and trust boundaries before a deeper investigation. +- `rdt doctor --session ` is a first-class alias for the same command. - It also reports `enginePreference`, `selectedEngine`, `recommendedEngine`, `availableEngines`, and DevTools capability hints so agents know whether they are on a custom fallback or a DevTools-aligned path. - `sourceCapability` is reported separately from engine selection. - `_debugSource` is treated as an optional legacy source-mapping capability, not an engine-selection gate. @@ -154,12 +158,15 @@ 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 ` + - `rdt interact click --session --selector [--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` 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. - `click`, `type`, and `press` confirm that the action was dispatched. They do not guarantee that the page or React tree has fully settled afterward. - When profiling or triggering large rerenders, follow `interact` with an explicit verification step such as `interact wait`, `tree get`, `node inspect`, or a profiler read command. @@ -168,10 +175,10 @@ Example deterministic flow: ```bash rdt tree get --session demo # => save snapshotId from output -rdt node search App --session demo --snapshot +rdt node search App --session demo --snapshot --structured rdt node inspect --session demo --snapshot rdt node highlight --session demo --snapshot -rdt source reveal --session demo --snapshot +rdt source reveal --session demo --snapshot --structured ``` Snapshot recovery: @@ -269,7 +276,11 @@ Use `node pick` when the agent knows the visible element but not the component n - `hooks` is a simplified serialized view of hook state from the inspected fiber. - `context` is a serialized view of current context dependencies for the inspected node. - `source` projects `_debugSource` when available; `null` is expected in many dev builds. +- `source reveal --structured` returns `status`, `available`, `mode`, `reason`, and `source` so automation can distinguish unavailable source data from a successful source payload. +- `source reveal` without `--structured` preserves the raw legacy behavior and may return literal `null`. - `dom` is the first host element summary used for CLI highlight and DOM-oriented inspection. +- `node search --structured` wraps search results in `{ items, query, snapshotId, matchCount, runtimeWarnings }`. +- When `node search --structured` returns `matchCount: 0`, `runtimeWarnings` explains that the component may be absent from the current snapshot rather than absent from the codebase. - Profiler summary fields are commit-oriented CLI metrics, not the full DevTools profiler session schema. - `profiler summary` and exported summaries explicitly report: - `measurementMode: "actual-duration" | "structural-only" | "mixed"` diff --git a/docs/devtools-concept-mapping.md b/docs/devtools-concept-mapping.md index 7313c84..c517992 100644 --- a/docs/devtools-concept-mapping.md +++ b/docs/devtools-concept-mapping.md @@ -34,16 +34,18 @@ It is a maintenance aid for agents and contributors. It is not a commitment to r | `__REACT_DEVTOOLS_GLOBAL_HOOK__` shim | `src/runtime-script.js` | DevTools global hook | `rdt` uses the hook for renderer/root discovery, but does not speak the official frontend protocol. | | `state.roots` + `rootId` | `src/runtime-script.js` | Fiber roots / renderer root registry | `rdt` assigns CLI-friendly `root-*` ids per discovered root. | | `collectTree()` snapshot | `src/runtime-script.js` | Inspected tree payload | `rdt` serializes a tree for CLI use instead of streaming updates to a frontend. | +| `tree stats` | `src/runtime-script.js` + `src/cli.js` | No direct public equivalent | Snapshot summary view for large trees; returns metadata without the full node dump. | | `snapshotId` | `src/runtime-script.js` | No direct public equivalent | Intentional divergence. This is a CLI-specific stability layer for follow-up commands. | | `node id` like `n68` | `src/runtime-script.js` | Element/fiber identity in inspector payloads | Snapshot-scoped only. Not intended to be globally stable across commits. | | `node inspect` payload | `src/runtime-script.js` | Inspected element details | Closest conceptual match to DevTools inspected element data. | | `ownerStack` | `src/runtime-script.js` | Owner chain / component stack | Serialized as lightweight `{id, displayName}` records. | | `hooks` | `src/runtime-script.js` | Hook inspection data | Derived from `memoizedState`; intentionally simpler than full DevTools hook typing. | | `context` | `src/runtime-script.js` | Context dependencies / inspected context values | Derived from fiber dependencies, serialized for CLI use. | -| `source reveal` | `src/server.js` + `src/runtime-script.js` | View source / inspect source location | Depends on `_debugSource`; may legitimately return `null`. | +| `source reveal` | `src/server.js` + `src/runtime-script.js` | View source / inspect source location | Raw mode may legitimately return `null`; structured mode reports source availability explicitly. | | `profiler` summary/export | `src/runtime-script.js` + `src/cli.js` | Profiler commit data | `rdt` keeps commit-oriented summaries and NDJSON export rather than full DevTools frontend state. | | `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. | | `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 @@ -72,6 +74,8 @@ It is a maintenance aid for agents and contributors. It is not a commitment to r - `hooks`: serialized hook state view, intentionally simplified. - `context`: serialized current context dependency values. - `source`: `_debugSource` projection when available; `null` is expected in many builds. +- `source reveal --structured`: wraps source lookup in `{ status, available, mode, reason, source }`. +- `mode`: source-capability mode, not engine mode. Current values describe whether `_debugSource` is available, removed by newer React runtimes, or stripped by the build. - `dom`: first host element descendant summary used for CLI-oriented highlight/reveal behavior. ### Profiler payload diff --git a/docs/workflows.md b/docs/workflows.md index 73fd99f..1aeb785 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -23,7 +23,8 @@ npm install -g react-devtool-cli ```bash rdt session open --url http://localhost:3000 --browser chromium --engine auto --session demo rdt tree get --session demo -rdt node search App --session demo --snapshot +rdt tree stats --session demo --top 5 +rdt node search App --session demo --snapshot --structured rdt node inspect --session demo --snapshot ``` @@ -40,11 +41,16 @@ Recommended flow: ```bash rdt tree get --session demo -rdt node search App --session demo --snapshot +rdt tree stats --session demo --top 5 +rdt node search App --session demo --snapshot --structured rdt node highlight --session demo --snapshot -rdt source reveal --session demo --snapshot +rdt source reveal --session demo --snapshot --structured ``` +- `tree stats` is the lightweight summary path when `tree get --format json` is too heavy. +- `node search --structured` keeps the default array-returning behavior opt-in while adding `matchCount` and `runtimeWarnings`. +- `source reveal --structured` returns availability metadata instead of only raw `null`. + Recovery flow: ```bash @@ -56,6 +62,7 @@ rdt node search App --session demo --snapshot ## Run `doctor` before deeper investigation ```bash +rdt doctor --session demo rdt session doctor --session demo ``` @@ -71,11 +78,14 @@ 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' +rdt interact click --session demo --selector 'button.save' --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. + After interaction, verify the app settled by collecting a fresh tree or reading profiler output instead of assuming the UI state changed correctly. ## Profile a real update diff --git a/package-lock.json b/package-lock.json index 64f92fe..d289d03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-devtool-cli", - "version": "0.1.35-rc.1", + "version": "0.1.35-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-devtool-cli", - "version": "0.1.35-rc.1", + "version": "0.1.35-rc.2", "license": "MIT", "dependencies": { "playwright": "1.58.2" diff --git a/package.json b/package.json index 8313f4d..4752368 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-devtool-cli", - "version": "0.1.35-rc.1", + "version": "0.1.35-rc.2", "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", diff --git a/src/cli.js b/src/cli.js index 57eceb0..9dbfd33 100644 --- a/src/cli.js +++ b/src/cli.js @@ -92,6 +92,10 @@ function collectSnapshotPayload(options) { return options.snapshot ? { snapshotId: String(options.snapshot) } : {}; } +function normalizeStructuredFlag(options) { + return Boolean(options.structured); +} + 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" }); @@ -278,9 +282,14 @@ async function handleSessionCommand(command, options) { } async function handleTreeCommand(command, options) { - ensure(command === "get", "Only `rdt tree get` is supported.", { code: "unsupported-command" }); + ensure(command === "get" || command === "stats", "Only `rdt tree get` and `rdt tree stats` are supported.", { + code: "unsupported-command", + }); ensure(options.session, "Missing required option --session", { code: "missing-session" }); - const response = await requestSession(options.session, "tree.get"); + const action = command === "stats" ? "tree.stats" : "tree.get"; + const response = await requestSession(options.session, action, { + top: options.top ? Number(options.top) : undefined, + }); writeStdout(response.result, resolveFormat(options)); } @@ -304,6 +313,7 @@ async function handleNodeCommand(command, positionals, options) { ensure(query, "Missing query for `rdt node search`.", { code: "missing-query" }); const response = await requestSession(options.session, "node.search", { query, + structured: normalizeStructuredFlag(options), ...collectSnapshotPayload(options), }); writeStdout(response.result, resolveFormat(options)); @@ -339,6 +349,7 @@ async function handleInteractCommand(command, options) { 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), + delivery: options.delivery ? String(options.delivery) : undefined, timeoutMs: options.timeoutMs ?? undefined, }); writeStdout(response.result, resolveFormat(options)); @@ -732,11 +743,30 @@ async function handleSourceCommand(command, positionals, options) { ensure(nodeId, "Missing node id for `rdt source reveal`.", { code: "missing-node-id" }); const response = await requestSession(options.session, "source.reveal", { nodeId, + structured: normalizeStructuredFlag(options), ...collectSnapshotPayload(options), }); writeStdout(response.result, resolveFormat(options)); } +export function normalizeCliPositionals(positionals) { + const [resource, command, ...rest] = positionals; + + if (resource === "doctor") { + return { + resource: "session", + command: "doctor", + rest: command ? [command, ...rest] : [], + }; + } + + return { + resource, + command, + rest, + }; +} + function printHelp() { process.stdout.write(`react-devtool-cli @@ -746,9 +776,10 @@ Recommended flow: 3. Use rdt session attach only for Chromium CDP compatibility 4. Use rdt tree/node/profiler commands for structured output 5. For agent workflows, capture snapshotId from tree get and pass it to later node/source commands - 6. Use rdt doctor before profiling if helper scripts or Playwright resolution look suspicious + 6. Use rdt doctor (alias: rdt session doctor) before profiling if helper scripts or Playwright resolution look suspicious Usage: + rdt doctor --session [--format json|yaml|pretty] rdt session open --url [--browser chromium|firefox|webkit] [--engine auto|custom|devtools] [--channel ] [--device ] [--storage-state ] [--user-data-dir ] [--timeout ] [--headless=false] [--session ] rdt session connect --ws-endpoint [--browser chromium|firefox|webkit] [--engine auto|custom|devtools] [--target-url ] [--timeout ] [--session ] rdt session attach --cdp-url [--engine auto|custom|devtools] [--target-url ] [--timeout ] [--session ] @@ -756,11 +787,12 @@ Usage: rdt session doctor --session [--format json|yaml|pretty] rdt session close --session rdt tree get --session [--format json|yaml|pretty] + rdt tree stats --session [--top ] [--format json|yaml|pretty] rdt node inspect --session [--snapshot ] [--commit ] - rdt node search --session [--snapshot ] + rdt node search --session [--snapshot ] [--structured] rdt node highlight --session [--snapshot ] rdt node pick --session [--timeout-ms 30000] - rdt interact click --session --selector [--timeout-ms ] + rdt interact click --session --selector [--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 @@ -773,7 +805,7 @@ Usage: rdt profiler flamegraph --session [--format json|pretty] rdt profiler compare --session --left --right [--format json|yaml|pretty] rdt profiler export --session [--output file.jsonl] [--compress] - rdt source reveal --session [--snapshot ] + rdt source reveal --session [--snapshot ] [--structured] Snapshot behavior: - tree get returns snapshotId @@ -795,7 +827,7 @@ export async function runCli(argv) { return; } - const [resource, command, ...rest] = positionals; + const { resource, command, rest } = normalizeCliPositionals(positionals); try { switch (resource) { diff --git a/src/runtime-script.js b/src/runtime-script.js index 0338cef..27610e1 100644 --- a/src/runtime-script.js +++ b/src/runtime-script.js @@ -194,6 +194,10 @@ export function runtimeBootstrap() { }; } + function isHostLikeTagName(tagName) { + return typeof tagName === "string" && tagName.startsWith("Host"); + } + function detectDevtoolsCapabilities(preferredEngine) { const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__ || null; const rendererCount = state.renderers.size; @@ -827,6 +831,109 @@ export function runtimeBootstrap() { }; } + function clampTopLimit(value, fallback = 10) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return fallback; + } + + return Math.max(1, Math.floor(numeric)); + } + + function summarizeRoot(root, nodesById) { + const node = nodesById.get(root.nodeId) || null; + return { + rootId: root.id, + rendererId: root.rendererId, + nodeId: root.nodeId, + displayName: node?.displayName || null, + tagName: node?.tagName || null, + }; + } + + function collectTopLevelComponents(snapshot, limit) { + const nodes = snapshot.nodes || []; + const seen = new Set(); + const components = []; + + for (const root of snapshot.roots || []) { + let candidates = nodes.filter((node) => { + return node.rootId === root.id && node.depth === 1 && !isHostLikeTagName(node.tagName); + }); + + if (candidates.length === 0) { + const fallbackDepth = nodes + .filter((node) => node.rootId === root.id && !isHostLikeTagName(node.tagName)) + .reduce((minimum, node) => Math.min(minimum, node.depth), Number.POSITIVE_INFINITY); + + if (Number.isFinite(fallbackDepth)) { + candidates = nodes.filter((node) => { + return node.rootId === root.id && node.depth === fallbackDepth && !isHostLikeTagName(node.tagName); + }); + } + } + + for (const candidate of candidates) { + const dedupeKey = `${candidate.rootId}:${candidate.displayName}:${candidate.tagName}:${candidate.depth}`; + if (seen.has(dedupeKey)) { + continue; + } + + seen.add(dedupeKey); + components.push({ + rootId: candidate.rootId, + nodeId: candidate.id, + displayName: candidate.displayName, + tagName: candidate.tagName, + depth: candidate.depth, + }); + + if (components.length >= limit) { + return components; + } + } + } + + return components; + } + + function treeStats(top, preferredEngine) { + const observedTree = collectTree(preferredEngine); + const nodes = observedTree.nodes || []; + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const topLimit = clampTopLimit(top, 10); + const topLevelComponents = observedTree.snapshotId + ? collectTopLevelComponents(observedTree, topLimit) + : []; + + return { + enginePreference: observedTree.enginePreference, + engine: observedTree.engine, + selectedEngine: observedTree.selectedEngine, + recommendedEngine: observedTree.recommendedEngine, + availableEngines: observedTree.availableEngines, + engineFallback: observedTree.engineFallback, + engineReasons: observedTree.engineReasons, + inspectionSource: observedTree.inspectionSource, + measurementSource: observedTree.measurementSource, + snapshotModel: observedTree.snapshotModel, + devtoolsCapabilities: observedTree.devtoolsCapabilities, + snapshotId: observedTree.snapshotId || null, + snapshotScoped: true, + identityStableAcrossCommits: false, + observationLevel: OBSERVED, + limitations: getSnapshotLimitations(), + runtimeWarnings: observedTree.runtimeWarnings || [], + generatedAt: observedTree.generatedAt || null, + reactDetected: observedTree.reactDetected, + rootCount: (observedTree.roots || []).length, + nodeCount: nodes.length, + rootSummaries: (observedTree.roots || []).map((root) => summarizeRoot(root, nodesById)), + topLevelComponents, + topLevelComponentCount: topLevelComponents.length, + }; + } + function cacheSnapshot(tree, preferredEngine) { const selectedEngine = getSelectedEngine(preferredEngine); const store = getSnapshotStore(selectedEngine); @@ -962,24 +1069,86 @@ export function runtimeBootstrap() { return details; } - function searchNodes(query, snapshotId, preferredEngine) { + function searchNodes(query, snapshotId, preferredEngine, structured) { const snapshot = resolveSnapshot(snapshotId, true, preferredEngine); if (snapshot?.__rdtError) { return snapshot; } if (!snapshot) { - return []; + return structured + ? { + items: [], + query: String(query || ""), + snapshotId: null, + matchCount: 0, + runtimeWarnings: [], + } + : []; } const lower = String(query || "").toLowerCase(); - return snapshot.nodes.filter((node) => { + const items = snapshot.nodes.filter((node) => { return String(node.displayName || "").toLowerCase().includes(lower); }).map((node) => ({ ...node, snapshotId: snapshot.snapshotId, engine: snapshot.selectedEngine || buildEngineMetadata(preferredEngine).selectedEngine, })); + + if (!structured) { + return items; + } + + const runtimeWarnings = items.length === 0 + ? [ + `No matches were found in snapshot "${snapshot.snapshotId}". The component may exist in the app but not be rendered in the current UI state.`, + "Do not treat a zero-match snapshot search as proof that the component is absent from the codebase.", + ] + : []; + + return { + items, + query: String(query || ""), + snapshotId: snapshot.snapshotId, + matchCount: items.length, + runtimeWarnings, + }; + } + + function revealSource(nodeId, snapshotId, commitId, preferredEngine, structured) { + const details = inspectNode(nodeId, snapshotId, commitId, preferredEngine); + if (details?.__rdtError) { + return details; + } + + if (!details) { + return createRuntimeError( + "node-not-found", + `Node "${nodeId}" is not available in the current snapshot or commit context.`, + { + nodeId, + snapshotId: snapshotId || null, + commitId: commitId || null, + }, + ); + } + + if (!structured) { + return details.source; + } + + const sourceCapability = getSourceCapability(details); + return { + status: details.source ? "available" : "unavailable", + available: Boolean(details.source), + mode: sourceCapability.mode, + reason: sourceCapability.reason, + rendererVersions: sourceCapability.rendererVersions, + snapshotId: details.snapshotId || snapshotId || null, + nodeId, + source: details.source || null, + }; } function ensureOverlay() { @@ -2004,6 +2173,8 @@ export function runtimeBootstrap() { profilerRanked: rankProfilerCommit, profilerFlamegraph: flamegraphProfilerCommit, profilerSummary: summarizeProfiler, + treeStats, + revealSource, doctor, }; } diff --git a/src/server.js b/src/server.js index 9f3d58e..9b92d11 100644 --- a/src/server.js +++ b/src/server.js @@ -207,6 +207,21 @@ function unwrapRuntimeResult(result) { return result; } +function normalizeClickDeliveryMode(value) { + if (value == null) { + return "auto"; + } + + const normalized = String(value); + if (normalized === "auto" || normalized === "playwright" || normalized === "dom") { + return normalized; + } + + throw new CliError(`Unsupported interact click delivery mode: ${normalized}`, { + code: "unsupported-delivery-mode", + }); +} + function parseServerArgv(argv) { const options = {}; @@ -615,12 +630,7 @@ class SessionServer { } } - async clickLocator(locator, timeoutMs) { - if (!await this.isProfilerActive()) { - await locator.click({ timeout: timeoutMs, noWaitAfter: true }); - return "playwright"; - } - + async clickLocatorDom(locator, timeoutMs) { await locator.waitFor({ state: "visible", timeout: timeoutMs }); await locator.scrollIntoViewIfNeeded({ timeout: timeoutMs }); const clicked = await locator.evaluate((element) => { @@ -651,6 +661,42 @@ class SessionServer { return "dom-click"; } + async clickLocator(locator, timeoutMs, requestedDelivery) { + const profilerActive = await this.isProfilerActive(); + + if (requestedDelivery === "playwright") { + await locator.click({ timeout: timeoutMs, noWaitAfter: true }); + return { + effectiveDelivery: "playwright", + profilerActive, + fallbackApplied: false, + }; + } + + if (requestedDelivery === "dom") { + return { + effectiveDelivery: await this.clickLocatorDom(locator, timeoutMs), + profilerActive, + fallbackApplied: false, + }; + } + + if (!profilerActive) { + await locator.click({ timeout: timeoutMs, noWaitAfter: true }); + return { + effectiveDelivery: "playwright", + profilerActive, + fallbackApplied: false, + }; + } + + return { + effectiveDelivery: await this.clickLocatorDom(locator, timeoutMs), + profilerActive, + fallbackApplied: true, + }; + } + async interact(command, payload) { const page = await this.ensureInteractivePage(); const timeoutMs = payload.timeoutMs ? Number(payload.timeoutMs) : this.timeoutMs; @@ -683,9 +729,16 @@ class SessionServer { : null; let delivery = command; const runtimeWarnings = []; + let requestedDelivery = null; + let profilerActive = false; + let fallbackApplied = false; if (command === "click") { - delivery = await this.clickLocator(locator, timeoutMs); + requestedDelivery = normalizeClickDeliveryMode(payload.delivery); + const clickResult = await this.clickLocator(locator, timeoutMs, requestedDelivery); + delivery = clickResult.effectiveDelivery; + profilerActive = clickResult.profilerActive; + fallbackApplied = clickResult.fallbackApplied; } else if (command === "type") { await locator.focus({ timeout: timeoutMs }); await locator.fill(String(payload.text), { timeout: timeoutMs }); @@ -701,9 +754,15 @@ class SessionServer { } runtimeWarnings.push("Interact actions confirm dispatch only; verify post-action UI state with follow-up commands when profiling or large rerenders are active."); - if (delivery === "dom-click") { + if (command === "click" && fallbackApplied) { runtimeWarnings.push("Profiler was active, so click used a DOM fallback instead of Playwright pointer input."); } + if (command === "click" && requestedDelivery === "playwright" && profilerActive) { + runtimeWarnings.push("Profiler is active and click delivery was forced to Playwright pointer input."); + } + if (command === "click" && requestedDelivery === "dom" && !profilerActive) { + runtimeWarnings.push("Click delivery was forced to DOM dispatch even though profiler fallback was not required."); + } return { observationLevel: "observed", @@ -712,6 +771,10 @@ class SessionServer { action: command, ok: true, delivery, + requestedDelivery, + effectiveDelivery: delivery, + profilerActive, + fallbackApplied, selector, target, key: payload.key ? String(payload.key) : null, @@ -778,6 +841,11 @@ class SessionServer { return this.close(); case "tree.get": return this.collectTree(); + case "tree.stats": + return this.page.evaluate( + ({ top, preferredEngine }) => window.__RDT_CLI_RUNTIME__.treeStats(top, preferredEngine), + { top: payload.top, preferredEngine: this.enginePreference }, + ); case "node.inspect": await this.ensureReactDetected(); return unwrapRuntimeResult(await this.page.evaluate( @@ -787,7 +855,9 @@ class SessionServer { case "node.search": await this.ensureReactDetected(); return unwrapRuntimeResult(await this.page.evaluate( - ({ query, snapshotId, preferredEngine }) => window.__RDT_CLI_RUNTIME__.searchNodes(query, snapshotId, preferredEngine), + ({ query, snapshotId, preferredEngine, structured }) => { + return window.__RDT_CLI_RUNTIME__.searchNodes(query, snapshotId, preferredEngine, structured); + }, { ...payload, preferredEngine: this.enginePreference }, )); case "node.highlight": @@ -857,10 +927,18 @@ class SessionServer { )); case "source.reveal": await this.ensureReactDetected(); - return unwrapRuntimeResult(await this.page.evaluate(({ nodeId, snapshotId, commitId, preferredEngine }) => { - const node = window.__RDT_CLI_RUNTIME__.inspectNode(nodeId, snapshotId, commitId, preferredEngine); - return node ? node.source : null; - }, { ...payload, preferredEngine: this.enginePreference })); + return unwrapRuntimeResult(await this.page.evaluate( + ({ nodeId, snapshotId, commitId, preferredEngine, structured }) => { + return window.__RDT_CLI_RUNTIME__.revealSource( + nodeId, + snapshotId, + commitId, + preferredEngine, + structured, + ); + }, + { ...payload, preferredEngine: this.enginePreference }, + )); default: throw new CliError(`Unsupported action: ${action}`, { code: "unsupported-action" }); } diff --git a/test/run-tests.js b/test/run-tests.js index 2107756..69123d7 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -6,7 +6,13 @@ import path from "node:path"; import { gzipSync } from "node:zlib"; import { parseArgv } from "../src/args.js"; -import { closeSessionWithFallback, compareProfiles, forceKillProcessTree, parseProfilerExportFile } from "../src/cli.js"; +import { + closeSessionWithFallback, + compareProfiles, + forceKillProcessTree, + normalizeCliPositionals, + parseProfilerExportFile, +} from "../src/cli.js"; import { formatOutput } from "../src/format.js"; import { requestSession } from "../src/http-client.js"; import { @@ -130,6 +136,8 @@ run("parseArgv preserves interact click options", () => { "app", "--selector", ".result-row", + "--delivery", + "dom", "--timeout-ms", "1200", ]); @@ -137,9 +145,71 @@ run("parseArgv preserves interact click options", () => { assert.deepEqual(parsed.positionals, ["interact", "click"]); assert.equal(parsed.options.session, "app"); assert.equal(parsed.options.selector, ".result-row"); + assert.equal(parsed.options.delivery, "dom"); assert.equal(parsed.options.timeoutMs, 1200); }); +run("parseArgv preserves tree stats options", () => { + const parsed = parseArgv([ + "tree", + "stats", + "--session", + "app", + "--top", + "5", + "--format", + "pretty", + ]); + + assert.deepEqual(parsed.positionals, ["tree", "stats"]); + assert.equal(parsed.options.session, "app"); + assert.equal(parsed.options.top, 5); + assert.equal(parsed.options.format, "pretty"); +}); + +run("parseArgv preserves source reveal structured flag", () => { + const parsed = parseArgv([ + "source", + "reveal", + "n12", + "--session", + "app", + "--snapshot", + "snapshot-3", + "--structured", + ]); + + assert.deepEqual(parsed.positionals, ["source", "reveal", "n12"]); + assert.equal(parsed.options.structured, true); + assert.equal(parsed.options.snapshot, "snapshot-3"); +}); + +run("parseArgv preserves node search structured flag", () => { + const parsed = parseArgv([ + "node", + "search", + "AlertPanel", + "--session", + "app", + "--structured", + ]); + + assert.deepEqual(parsed.positionals, ["node", "search", "AlertPanel"]); + assert.equal(parsed.options.session, "app"); + assert.equal(parsed.options.structured, true); +}); + +run("normalizeCliPositionals maps doctor alias to session doctor", () => { + const parsed = parseArgv(["doctor", "--session", "app"]); + const normalized = normalizeCliPositionals(parsed.positionals); + + assert.deepEqual(normalized, { + resource: "session", + command: "doctor", + rest: [], + }); +}); + run("parseArgv preserves interact type options", () => { const parsed = parseArgv([ "interact",