diff --git a/.github/workflows/wayland-e2e.yml b/.github/workflows/wayland-e2e.yml index c39499e0..ef6be5c9 100644 --- a/.github/workflows/wayland-e2e.yml +++ b/.github/workflows/wayland-e2e.yml @@ -232,7 +232,10 @@ jobs: curl -sS -m 60 -X POST http://127.0.0.1:3033/tools/screenshot \ -H 'Content-Type: application/json' \ -d '{"udid":"emulator-5554"}' > /tmp/shot.json - PATH_PNG=$(python3 -c "import json,sys;print(json.load(open('/tmp/shot.json'))['data']['path'])") + # The screenshot tool returns an ArtifactHandle ({ image: { hostPath, ... } }) + # since the remote-artifacts change; the job runs co-located with the + # tool-server so hostPath is directly readable. + PATH_PNG=$(python3 -c "import json,sys;print(json.load(open('/tmp/shot.json'))['data']['image']['hostPath'])") cp "$PATH_PNG" /tmp/wayland-cold-boot.png SZ=$(stat -c%s /tmp/wayland-cold-boot.png) echo "size=${SZ}B" diff --git a/packages/argent-mcp/src/auto-screenshot.ts b/packages/argent-mcp/src/auto-screenshot.ts index 542785ea..041347ef 100644 --- a/packages/argent-mcp/src/auto-screenshot.ts +++ b/packages/argent-mcp/src/auto-screenshot.ts @@ -11,6 +11,8 @@ import { isFlagEnabled, type FlagsPathOptions } from "@argent/configuration-core export const AUTO_SCREENSHOT_TOOLS = new Set([ "gesture-tap", "gesture-swipe", + "gesture-scroll", + "gesture-drag", "gesture-custom", "gesture-pinch", "gesture-rotate", @@ -33,6 +35,8 @@ export const AUTO_SCREENSHOT_DELAY_MS_BY_TOOL: Record = { "restart-app": 3000, "open-url": 2000, "gesture-swipe": 1500, + "gesture-scroll": 1500, + "gesture-drag": 1500, "gesture-custom": 1500, "gesture-tap": 1500, "gesture-pinch": 1500, diff --git a/packages/argent-mcp/src/mcp-server.ts b/packages/argent-mcp/src/mcp-server.ts index 56fc87d5..b06a1e46 100644 --- a/packages/argent-mcp/src/mcp-server.ts +++ b/packages/argent-mcp/src/mcp-server.ts @@ -208,7 +208,7 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise boolean; } diff --git a/packages/skills/rules/argent.md b/packages/skills/rules/argent.md index 0c97eb4d..7cb5ed89 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -4,7 +4,7 @@ alwaysApply: true --- -Argent MCP tools are available in this project for iOS simulator and Android emulator control. Argent MCP tools are the preferred form of interaction with the application. +Argent MCP tools are available in this project for iOS simulator, Android emulator, and Electron desktop app control. Argent MCP tools are the preferred form of interaction with the application. Running MCP server and managing the Argent toolkit utilises `argent` command - if asked use `argent --help` for reference. To check current version of MCP server run `argent --version` command. @@ -17,6 +17,7 @@ Use cases: - Any request to execute manual QA, UI QA, or visual behavior validation for a mobile app - Running, debugging, or testing a React Native app (iOS or Android) - Profiling performance or diagnosing re-renders in a React Native app (iOS or Android) +- Running, debugging, or testing an Electron desktop app (boot with `boot-device` + `electronAppPath`; on Electron scroll with `gesture-scroll` and drag with `gesture-drag` — `gesture-swipe` is touch-only) @@ -44,7 +45,7 @@ Before booting, running, or interacting with any app, call `list-devices` first Decision order: 1. **Explicit user intent** - choose the user named platform or device. Look for words "simulator" and "emulator". -2. **Prefer a running device.** iOS simulators - state `Booted` and Android devices - `state: "device"` come first in `list-devices`. +2. **Prefer a running device.** iOS simulators - state `Booted` and Android devices - `state: "device"` come first in `list-devices`; Electron apps appear as `platform: "electron"`, `state: "Running"`. 3. **Single-platform project:** (per `argent-environment-inspector` flags `is_native_ios`/`is_native_android`, or RN with only one platform configured) → boot that platform. diff --git a/packages/skills/skills/argent-device-interact/SKILL.md b/packages/skills/skills/argent-device-interact/SKILL.md index e2911ccd..54cf2e0a 100644 --- a/packages/skills/skills/argent-device-interact/SKILL.md +++ b/packages/skills/skills/argent-device-interact/SKILL.md @@ -1,11 +1,11 @@ --- name: argent-device-interact -description: Interact with an iOS simulator or Android emulator using argent MCP tools. Use when tapping UI elements, performing gestures, scrolling/swiping, typing text, pressing hardware buttons, launching apps, opening URLs, taking screenshots, or checking visible app state after interactions. +description: Interact with an iOS simulator, Android emulator, or Electron app using argent MCP tools. Use when tapping UI elements, performing gestures, scrolling/swiping, typing text, pressing hardware buttons, launching apps, opening URLs, taking screenshots, or checking visible app state after interactions. --- ## Unified tool surface -All interaction tools below accept a `udid` parameter and auto-dispatch iOS vs Android based on its shape (UUID → iOS simulator, anything else → Android adb serial). You use the same tool names on both platforms. +All interaction tools below accept a `udid` parameter and auto-dispatch iOS vs Android based on its shape (UUID → iOS simulator, `electron-cdp-` → Electron app, anything else → Android adb serial). You use the same tool names on both platforms. For platform-specific caveats (Metro `adb reverse`, locked-screen describe errors, etc.), see § 9 Platform-specific notes at the bottom. @@ -13,7 +13,7 @@ For platform-specific caveats (Metro `adb reverse`, locked-screen describe error If you delegate simulator tasks to sub-agents, make sure they have MCP permissions. -Use `list-devices` to get a target id. Results are tagged with `platform` (`ios` or `android`); booted/ready devices come first. Pick the first entry that matches the platform you need — if none are ready, call `boot-device` with `udid` (iOS) or `avdName` (Android). See `argent-ios-simulator-setup` / `argent-android-emulator-setup` for full setup flow. +Use `list-devices` to get a target id. Results are tagged with `platform` (`ios`, `android`, or `electron`); booted/ready devices come first. Pick the first entry that matches the platform you need — if none are ready, call `boot-device` with `udid` (iOS), `avdName` (Android), or `electronAppPath` (Electron). See `argent-ios-simulator-setup` / `argent-android-emulator-setup` for full setup flow. **Load tool schemas before first use.** Gesture tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`) may be deferred — their parameter schemas are not loaded until fetched. Always use ToolSearch to load the schemas of all gesture tools you plan to use **before** calling any of them. If you skip this step, parameters may be coerced to strings instead of numbers, causing validation errors. @@ -21,7 +21,7 @@ Use `list-devices` to get a target id. Results are tagged with `platform` (`ios` 1. **Always refer to tapping_rule** from your argent.md rule before tapping. 2. Before performing interactions, consider whether they can be **dispatched sequentially** - more on that in `run-sequence`. -3. **Use `gesture-swipe` for lists/scrolling**, not `gesture-custom`, unless you need non-linear movement. Consider whether you need multiple swipes, if yes - use `run-sequence`. +3. **Use `gesture-swipe` for lists/scrolling**, not `gesture-custom`, unless you need non-linear movement. On Electron use `gesture-scroll` instead — `gesture-swipe` is touch-only. Consider whether you need multiple swipes, if yes - use `run-sequence`. 4. **Tap a text field before typing** — on iOS try `paste` first then fall back to `keyboard`; on Android use `keyboard` directly (`paste` is iOS-only). 5. **Coordinates are normalized** — always 0.0–1.0, not pixels. 6. **For app navigation, prefer `describe` first.** It works on any screen without app restart. Do not navigate from screenshots on regular in-app screens unless `describe` failed to expose a reliable target. Use `native-describe-screen` only when you need app-scoped UIKit properties. @@ -48,35 +48,37 @@ Common schemes: `messages://`, `settings://`, `maps://?q=`, `tel://` value returned by `boot-device`. Only `debugger-connect`, `debugger-status`, `debugger-evaluate`, and `debugger-log-registry` work on Electron; `debugger-component-tree`, `debugger-reload-metro`, `debugger-inspect-element`, the `view-network-*` tools, and the `react-profiler-*` / `profiler-*` tools are RN-only and reject Electron at the capability gate with `Tool 'X' is not supported on electron app`. ### Android: reverse port for Metro @@ -25,10 +27,10 @@ One Metro port can serve multiple connected devices (e.g. two simulators on `loc ### Connect & diagnostics -| Tool | Purpose | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `debugger-connect` | Connect to Metro CDP. Returns port, projectRoot, deviceName, appName, `logicalDeviceId`, isNewDebugger, connected. The returned `logicalDeviceId` is the `device_id` for every subsequent debugger/network/profiler call. | -| `debugger-status` | Like connect + loadedScripts, enabledDomains, sourceMapReady. **Use to diagnose.** | +| Tool | Purpose | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `debugger-connect` | Connect to the JS runtime's CDP (Metro on iOS / Android; the page CDP session on Electron). Returns port, projectRoot (empty on Electron), deviceName, appName, `logicalDeviceId`, isNewDebugger, connected. The returned `logicalDeviceId` is the `device_id` for every subsequent debugger call. | +| `debugger-status` | Like connect + loadedScripts, enabledDomains, sourceMapReady (no-op on Electron). **Use to diagnose.** | ### Reload & recovery @@ -115,13 +117,13 @@ When reading from the log file: ## Quick Reference -| Action | Tool | -| ----------------------------- | ------------------------------------------------------------------- | -| Diagnose / check connection | `debugger-status` | -| Connect to Metro CDP | `debugger-connect` | -| Reload JS (already connected) | `debugger-reload-metro` | -| Relaunch app on device | `restart-app` | -| Inspect component at point | `debugger-inspect-element` | -| Full component tree | `debugger-component-tree` | -| Console log overview | `debugger-log-registry` (summary + log file path for `Grep`/`Read`) | -| Evaluate JS | `debugger-evaluate` | +| Action | Tool | +| --------------------------------- | ------------------------------------------------------------------- | +| Diagnose / check connection | `debugger-status` | +| Connect to CDP (Metro / Electron) | `debugger-connect` | +| Reload JS (already connected) | `debugger-reload-metro` | +| Relaunch app on device | `restart-app` | +| Inspect component at point | `debugger-inspect-element` | +| Full component tree | `debugger-component-tree` | +| Console log overview | `debugger-log-registry` (summary + log file path for `Grep`/`Read`) | +| Evaluate JS | `debugger-evaluate` | diff --git a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md index cd167e2b..8683253b 100644 --- a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md +++ b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md @@ -235,13 +235,13 @@ If the user's intent is ambiguous (run existing tests, write new tests, or find ## Related Skills -| Skill | When to use | -| ------------------------------- | ------------------------------------------------------------------------------- | -| `argent-ios-simulator-setup` | Initial iOS simulator boot and connection setup | -| `argent-android-emulator-setup` | Initial Android emulator boot and connection setup | -| `argent-device-interact` | Tapping, swiping, typing, hardware buttons, gestures on the simulator/emulator | -| `argent-metro-debugger` | Full Metro CDP debugging: component inspection, console logs, JS evaluation | -| `argent-react-native-profiler` | Profiling performance, finding re-render issues, CPU hotspots | -| `argent-test-ui-flow` | Interactive UI testing with automatic screenshot verification after each action | +| Skill | When to use | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `argent-ios-simulator-setup` | Initial iOS simulator boot and connection setup | +| `argent-android-emulator-setup` | Initial Android emulator boot and connection setup | +| `argent-device-interact` | Tapping, swiping, typing, hardware buttons, gestures on the simulator/emulator | +| `argent-metro-debugger` | JS-runtime CDP debugging (Metro on iOS / Android; the four ported tools also drive Electron): component inspection, console logs, JS evaluation | +| `argent-react-native-profiler` | Profiling performance, finding re-render issues, CPU hotspots | +| `argent-test-ui-flow` | Interactive UI testing with automatic screenshot verification after each action | Ask the user before running tests: confirm which test suite (unit, E2E, or both), whether to use existing CI commands, and whether they want you to run existing tests, write new ones, or explore test cases yourself. diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index 3ea0c01f..2a502177 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -306,7 +306,7 @@ export const axServiceBlueprint: ServiceBlueprint = { const { device } = opts; if (device.platform !== "ios") { throw new Error( - `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — describe falls back to uiautomator on Android, which does not need this service.` + `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as ${device.platform} — describe uses uiautomator on Android and the CDP DOM walker on Electron, neither of which needs this service.` ); } // Reject before spawning. An undefined `device.id` slips through when an diff --git a/packages/tool-server/src/blueprints/electron-cdp.ts b/packages/tool-server/src/blueprints/electron-cdp.ts new file mode 100644 index 00000000..a721b06d --- /dev/null +++ b/packages/tool-server/src/blueprints/electron-cdp.ts @@ -0,0 +1,249 @@ +import { + TypedEventEmitter, + type DeviceInfo, + type ServiceBlueprint, + type ServiceEvents, + type ServiceInstance, +} from "@argent/registry"; +import type { CDPClient } from "../utils/debugger/cdp-client"; +import { + createElectronServer, + discoverPrimaryPage, + ensureCdpReachable, + type ElectronServer, + type MediaReady, + type ScreencastFrame, + type ScreencastOpts, + type ScreencastSession, + type ScreenshotOpts, +} from "../electron-server"; +import { parseElectronCdpPort, resolveDevice } from "../utils/device-info"; + +export const ELECTRON_CDP_NAMESPACE = "ElectronCdp"; + +type ElectronFactoryOptions = Record & { device: DeviceInfo }; + +/** + * Build the `ServiceRef` for an Electron CDP session keyed by an already-resolved + * `DeviceInfo`. Tool `services()` callbacks call this rather than hand-building + * the URN string so the blueprint factory always receives the device through + * the registry's `options` channel and never has to reclassify. + */ +export function electronCdpRef(device: DeviceInfo): { + urn: string; + options: ElectronFactoryOptions; +} { + return { + urn: `${ELECTRON_CDP_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + +// ── Legacy compatibility surface ───────────────────────────────────────────── +// The first cut of Electron support exposed a thin `ElectronCdpApi` directly +// off the blueprint. Existing tools (gesture-tap, screenshot, describe, +// keyboard, run-sequence, etc.) still consume that shape. The full ElectronServer +// is now the source of truth, and these legacy types are kept so the blueprint +// can publish *both* the new abstraction (`server`) and the original ergonomic +// methods without forcing a callsite-by-callsite migration. + +export interface MouseEventArgs { + type: "mousePressed" | "mouseReleased" | "mouseMoved"; + /** CSS pixels relative to the page viewport. */ + x: number; + y: number; + button?: "none" | "left" | "middle" | "right"; + /** Required for press/release. */ + clickCount?: number; +} + +export interface KeyEventArgs { + type: "keyDown" | "keyUp" | "rawKeyDown" | "char"; + /** Browser-style key value, e.g. "a", "Enter", "ArrowLeft". */ + key?: string; + code?: string; + text?: string; + /** DOM keyCode (deprecated but still consumed by many apps). */ + windowsVirtualKeyCode?: number; + modifiers?: number; +} + +export interface ViewportSize { + width: number; + height: number; + /** Device pixel ratio reported by the renderer. */ + devicePixelRatio: number; +} + +export interface ElectronAxNode { + nodeId: string; + role?: string; + name?: string; + value?: string; + description?: string; + ignored?: boolean; + backendDOMNodeId?: number; + childIds?: string[]; + properties?: Array<{ name: string; value: { value?: unknown; type: string } }>; +} + +export interface ElectronCdpApi { + /** CDP port the Electron app exposed. */ + port: number; + /** Underlying CDP client connected to the primary page target. */ + cdp: CDPClient; + /** WebSocket URL to the page target (for diagnostics). */ + pageWebSocketUrl: string; + /** Backend node id of the document (used as the root for AX queries). */ + rootDomNodeId: number | null; + /** The full sim-server-equivalent abstraction layer. New callers should use this. */ + server: ElectronServer; + /** Re-read the page viewport so normalized → CSS pixel math stays accurate after window resizes. */ + refreshViewport(): Promise; + /** Cached viewport from the most recent connect / refresh. */ + getViewport(): ViewportSize; + dispatchMouseEvent(event: MouseEventArgs): Promise; + dispatchKeyEvent(event: KeyEventArgs): Promise; + /** Screenshot via CDP, persisted under tmpdir; returns file:// URL + absolute path. + * Supports the sim-server-style options (rotation, scale, downscaler) when sharp is installed. */ + captureScreenshot(opts?: ScreenshotOpts): Promise; + /** Returns the accessibility tree rooted at the document. */ + getAxTree(): Promise; + /** Navigate the renderer to a URL. */ + navigate(url: string): Promise; + /** Evaluate JS in the renderer. Resolves to the serialized value when `returnByValue` is true. */ + evaluate(expression: string, options?: { returnByValue?: boolean }): Promise; + /** Start a screencast (one CDP session shared across all subscribers). */ + startScreencast(opts?: ScreencastOpts): Promise; + /** Last received screencast frame, or null. */ + getLastFrame(): ScreencastFrame | null; +} + +// Re-exports for discovery callers that previously imported these straight from +// the blueprint module. +export { discoverPrimaryPage, ensureCdpReachable }; + +async function getDocumentNodeId(cdp: CDPClient): Promise { + try { + const out = (await cdp.send("DOM.getDocument", { depth: 0 })) as { + root?: { nodeId?: number; backendNodeId?: number }; + }; + return out.root?.nodeId ?? out.root?.backendNodeId ?? null; + } catch { + return null; + } +} + +export const electronCdpBlueprint: ServiceBlueprint = { + namespace: ELECTRON_CDP_NAMESPACE, + getURN(device: DeviceInfo) { + return `${ELECTRON_CDP_NAMESPACE}:${device.id}`; + }, + async factory(_deps, payload, options) { + // Two routes into this factory: + // 1) A tool's `services()` callback uses electronCdpRef(device) and we + // get options.device for free. + // 2) Another blueprint declares `ElectronCdp:` as a transitive dep + // (registry resolves deps via URN strings only, no options channel + // — see Registry._resolve). In that case we synthesize DeviceInfo + // from the URN payload, which IS the device id. + // Both paths must agree on the device id; if a caller passed an explicit + // options.device whose id doesn't match the URN, that's a wiring bug + // worth surfacing loudly. + const opts = options as unknown as ElectronFactoryOptions | undefined; + const deviceFromOpts = opts?.device; + const payloadStr = typeof payload === "string" ? payload : (payload as DeviceInfo)?.id; + if (deviceFromOpts && payloadStr && deviceFromOpts.id !== payloadStr) { + throw new Error( + `${ELECTRON_CDP_NAMESPACE}.factory: options.device.id "${deviceFromOpts.id}" disagrees with URN payload "${payloadStr}".` + ); + } + const device = deviceFromOpts ?? (payloadStr ? resolveDevice(payloadStr) : null); + if (!device) { + throw new Error( + `${ELECTRON_CDP_NAMESPACE}.factory could not determine the device — pass it via electronCdpRef(device).options or via the URN payload.` + ); + } + const port = parseElectronCdpPort(device.id); + if (port == null) { + throw new Error( + `${ELECTRON_CDP_NAMESPACE}.factory got a malformed device id "${device.id}". ` + + `Expected "electron-cdp-".` + ); + } + + const server = await createElectronServer({ deviceId: device.id, port }); + const rootDomNodeId = await getDocumentNodeId(server.cdp); + + const events = new TypedEventEmitter(); + server.events.on("terminated", (err) => { + events.emit("terminated", err ?? new Error(`Electron CDP on port ${port} disconnected`)); + }); + + // Legacy adapter — translates the original `dispatchMouseEvent` and + // `dispatchKeyEvent` calls into the new server's wire formats. Keeping + // these one-liners means we don't have to rewrite every tool right now; + // they can migrate to `api.server.send*` at their own pace. + const api: ElectronCdpApi = { + port, + cdp: server.cdp, + pageWebSocketUrl: server.pageWebSocketUrl, + rootDomNodeId, + server, + getViewport: () => server.getViewport(), + refreshViewport: () => server.refreshViewport(), + dispatchMouseEvent: async (event: MouseEventArgs) => { + if (!Number.isFinite(event.x) || !Number.isFinite(event.y)) { + throw new Error( + `Electron CDP: dispatchMouseEvent received non-finite coords x=${event.x}, y=${event.y}.` + ); + } + const button = event.button ?? (event.type === "mouseMoved" ? "none" : "left"); + const buttons = button === "none" ? 0 : 1; + const payload: Record = { + type: event.type, + x: event.x, + y: event.y, + button, + buttons, + }; + if (event.type !== "mouseMoved") { + payload.clickCount = event.clickCount ?? 1; + } + await server.cdp.send("Input.dispatchMouseEvent", payload); + }, + dispatchKeyEvent: async (event: KeyEventArgs) => { + const payload: Record = { type: event.type }; + if (event.key !== undefined) payload.key = event.key; + if (event.code !== undefined) payload.code = event.code; + if (event.text !== undefined) payload.text = event.text; + if (event.windowsVirtualKeyCode !== undefined) { + payload.windowsVirtualKeyCode = event.windowsVirtualKeyCode; + } + if (event.modifiers !== undefined) payload.modifiers = event.modifiers; + await server.cdp.send("Input.dispatchKeyEvent", payload); + }, + captureScreenshot: (opts2?: ScreenshotOpts) => server.captureScreenshot(opts2), + getAxTree: async () => { + const out = (await server.cdp.send("Accessibility.getFullAXTree", {})) as { + nodes?: ElectronAxNode[]; + }; + return out.nodes ?? []; + }, + navigate: (url: string) => server.navigate(url), + evaluate: (expression: string, opts2?: { returnByValue?: boolean }) => + server.evaluate(expression, opts2), + startScreencast: (opts2?: ScreencastOpts) => server.startScreencast(opts2), + getLastFrame: () => server.getLastFrame(), + }; + + const instance: ServiceInstance = { + api, + dispose: async () => { + await server.dispose(); + }, + events, + }; + return instance; + }, +}; diff --git a/packages/tool-server/src/blueprints/electron-js-runtime-debugger.ts b/packages/tool-server/src/blueprints/electron-js-runtime-debugger.ts new file mode 100644 index 00000000..ad22adf0 --- /dev/null +++ b/packages/tool-server/src/blueprints/electron-js-runtime-debugger.ts @@ -0,0 +1,246 @@ +import { WebSocketServer, WebSocket } from "ws"; +import * as http from "node:http"; +import { + TypedEventEmitter, + type DeviceInfo, + type ServiceBlueprint, + type ServiceEvents, +} from "@argent/registry"; +import { ELECTRON_CDP_NAMESPACE, type ElectronCdpApi } from "./electron-cdp"; +import type { ConsoleAPICalledParams } from "../utils/debugger/cdp-client"; +import { SourceMapsRegistry } from "../utils/debugger/source-maps"; +import type { SourceResolver } from "../utils/debugger/source-resolver"; +import { LogFileWriter } from "../utils/debugger/log-file-writer"; +import { + type ConsoleLogEntry, + type ConsoleLogEvents, + type JsRuntimeDebuggerApi, +} from "./js-runtime-debugger"; + +export const ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE = "ElectronJsRuntimeDebugger"; + +type ElectronJsdFactoryOptions = Record & { device: DeviceInfo }; + +export function electronJsRuntimeDebuggerRef(device: DeviceInfo): { + urn: string; + options: ElectronJsdFactoryOptions; +} { + return { + urn: `${ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + +function formatConsoleArgs(params: ConsoleAPICalledParams): string { + return params.args + .map((arg) => { + if (arg.value !== undefined) return String(arg.value); + if (arg.description) return arg.description; + return `[${arg.type}]`; + }) + .join(" "); +} + +function createConsoleLogServer( + consoleEvents: TypedEventEmitter, + logWriter: LogFileWriter +): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = http.createServer(); + const wss = new WebSocketServer({ server }); + + wss.on("connection", (ws) => { + for (const entry of logWriter.readAll()) { + ws.send(JSON.stringify(entry)); + } + const onLog = (entry: ConsoleLogEntry) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(entry)); + } + }; + consoleEvents.on("log", onLog); + ws.on("close", () => consoleEvents.off("log", onLog)); + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Failed to bind console log server")); + return; + } + const url = `ws://127.0.0.1:${addr.port}`; + resolve({ + url, + close: () => + new Promise((res) => { + wss.clients.forEach((c) => c.close()); + wss.close(() => server.close(() => res())); + }), + }); + }); + + server.on("error", reject); + }); +} + +// Stubs for fields only consumed by debugger-inspect-element, which is locked +// out on Electron (it depends on the React Native internal +// getInspectorDataForViewAtPoint). Keeping them shaped means electron and +// metro paths can share a single api interface — tools that *don't* use them +// work uniformly, and any future tool that calls one on an electron api hits a +// loud, clearly-named error instead of `undefined`. +function makeStubSourceResolver(): SourceResolver { + const unsupported = () => { + throw new Error( + "SourceResolver is not implemented on Electron debugger sessions — Metro symbolicate is the only backing implementation." + ); + }; + return { + resolveDebugStack: async () => null, + symbolicate: async () => null, + readSourceFragment: unsupported, + }; +} + +class StubSourceMapsRegistry extends SourceMapsRegistry { + constructor() { + super(""); + } + override async waitForPending(): Promise { + // No Metro source-map fetch loop on Electron — page scripts already carry + // their own //# sourceMappingURL=data:... or rely on the browser devtools' + // own resolution path. + } +} + +export const electronJsRuntimeDebuggerBlueprint: ServiceBlueprint = { + namespace: ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE, + + getURN(payload: string) { + return `${ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE}:${payload}`; + }, + + getDependencies(_payload: string) { + // The payload IS the device id (e.g. "electron-cdp-9222") so we depend on + // the matching ElectronCdp service. Keeping the device id in the payload — + // rather than passing through options — means the registry can compute + // dependency URNs without needing the resolved DeviceInfo. + return { electron: `${ELECTRON_CDP_NAMESPACE}:${_payload}` }; + }, + + async factory(deps, payload, options) { + const opts = options as ElectronJsdFactoryOptions | undefined; + const device = opts?.device; + if (!device) { + throw new Error( + `${ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use electronJsRuntimeDebuggerRef(device) when registering the service ref.` + ); + } + if (device.id !== payload) { + throw new Error( + `${ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE}.factory: payload "${payload}" does not match options.device.id "${device.id}".` + ); + } + + const electron = deps.electron as ElectronCdpApi; + const cdp = electron.cdp; + const port = electron.port; + + // Attach the terminated bridge *before* any awaits below. This is mainly + // about post-factory disconnects: once the registry binds to our `events` + // (after factory returns), a `disconnected` here translates cleanly to + // `terminated` so the service is torn down. The disconnect-DURING-factory + // window is handled by the upstream ElectronCdp service, which has its own + // `terminated` event already bound to the registry — when it fires, the + // registry cascades teardown into us. So this listener and the upstream + // one cooperate: upstream covers the factory-init window; this one covers + // everything after factory returns. The dispose closure must `off` both + // listeners symmetrically — otherwise the upstream `cdp.events` outlives + // our blueprint and would emit into a disposed event bus. + const events = new TypedEventEmitter(); + const onDisconnected = (error?: Error) => { + events.emit("terminated", error ?? new Error("Electron CDP disconnected")); + }; + cdp.events.on("disconnected", onDisconnected); + + const logWriter = new LogFileWriter(port); + const consoleEvents = new TypedEventEmitter(); + let nextLogId = 0; + + const onConsoleAPI = (params: ConsoleAPICalledParams) => { + // Chrome's consoleAPICalled.timestamp is ms-since-epoch; Hermes' is + // seconds (which the Metro blueprint multiplies by 1000). Either source + // can theoretically hand us a non-finite number (CDP server bug, future + // protocol revision). new Date(NaN).toISOString() throws RangeError — + // since this fires inside a typed emitter that try/catches listeners, + // a throw here silently drops the entry. Coerce defensively. + const ts = Number.isFinite(params.timestamp) ? params.timestamp : Date.now(); + const entry: ConsoleLogEntry = { + id: nextLogId++, + level: params.type, + args: params.args.map((a) => ({ + type: a.type, + value: a.value, + description: a.description, + })), + message: formatConsoleArgs(params), + timestamp: ts, + stackTrace: params.stackTrace as ConsoleLogEntry["stackTrace"], + }; + logWriter.write({ + id: entry.id, + timestamp: new Date(ts).toISOString(), + level: entry.level, + message: entry.message, + stackTrace: entry.stackTrace, + }); + consoleEvents.emit("log", entry); + }; + cdp.events.on("consoleAPICalled", onConsoleAPI); + + const consoleServer = await createConsoleLogServer(consoleEvents, logWriter); + + // Best-effort: bind a callback name so evaluateWithBinding works if a + // future Electron tool wants it. Failure is non-fatal — the existing + // four ported tools don't use it. Future tools that DO use bindings must + // re-attempt addBinding themselves and surface their own errors loudly. + await cdp.addBinding("__argent_callback").catch(() => {}); + + const sourceMaps = new StubSourceMapsRegistry(); + const sourceResolver = makeStubSourceResolver(); + + const api: JsRuntimeDebuggerApi = { + port, + // Electron apps have no Metro project root. Empty string keeps the + // contract type-clean; callers that care (only inspect-element via the + // source resolver) are gated out before they ever touch this field. + projectRoot: "", + deviceName: device.name ?? "Electron", + appName: "Electron", + logicalDeviceId: device.id, + // Electron always speaks the new CDP — there is no Hermes-legacy mode. + isNewDebugger: true, + cdp, + sourceResolver, + sourceMaps, + logWriter, + consoleEvents, + consoleSocketUrl: consoleServer.url, + }; + + return { + api, + dispose: async () => { + cdp.events.off("consoleAPICalled", onConsoleAPI); + cdp.events.off("disconnected", onDisconnected); + await consoleServer.close(); + logWriter.close(); + // Do NOT disconnect the cdp — it belongs to the ElectronCdp service. + // Disposing this blueprint must leave the underlying CDP session alive + // for other consumers (screenshot, describe, gesture-tap, ...). + }, + events, + }; + }, +}; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 1b009111..06531dc5 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -355,7 +355,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint {}); - } else { + } else if (device.platform === "android") { await ensureDep("adb"); + } else { + // The simulator-server binary only knows iOS and Android. Other platforms + // (Electron) have their own blueprints (electron-cdp); reaching this + // factory with one means a tool's services() wired the wrong ref. + throw new Error( + `${SIMULATOR_SERVER_NAMESPACE}.factory does not support platform "${device.platform}". Use the platform-specific service blueprint instead.` + ); } const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess( diff --git a/packages/tool-server/src/electron-server/cdp-session.ts b/packages/tool-server/src/electron-server/cdp-session.ts new file mode 100644 index 00000000..aed3d097 --- /dev/null +++ b/packages/tool-server/src/electron-server/cdp-session.ts @@ -0,0 +1,100 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CDPClient } from "../utils/debugger/cdp-client"; + +interface CdpTarget { + id: string; + type: string; + title: string; + url: string; + webSocketDebuggerUrl?: string; +} + +export interface CdpVersionInfo { + "Browser"?: string; + "webSocketDebuggerUrl"?: string; + "Protocol-Version"?: string; +} + +/** GET `/json/version` — used by discovery to confirm CDP is alive. */ +export async function ensureCdpReachable( + port: number, + signal?: AbortSignal +): Promise { + return fetchJson(`http://127.0.0.1:${port}/json/version`, signal); +} + +/** + * Probe a CDP endpoint for the renderer page we should drive. Electron + * typically exposes one "page" target per BrowserWindow plus a few + * service_worker / shared_worker entries we don't care about. + * + * Throws loudly when the only pages are devtools:// URLs — driving input into + * the inspector instead of the real window is a hard-to-debug failure mode. + */ +export async function discoverPrimaryPage(port: number, signal?: AbortSignal): Promise { + const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`, signal); + const pages = targets.filter((t) => t.type === "page" && !!t.webSocketDebuggerUrl); + if (pages.length === 0) { + throw new Error( + `Electron CDP on port ${port} reported no page targets. Is the app started with --remote-debugging-port=${port}?` + ); + } + const primary = pages.find((p) => !p.url.startsWith("devtools://")); + if (!primary) { + throw new Error( + `Electron CDP on port ${port} has only devtools:// pages (the main BrowserWindow may be hidden or closed). ` + + `Bring the app window to the foreground and retry.` + ); + } + return primary; +} + +async function fetchJson(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { signal }); + if (!res.ok) { + throw new Error(`Electron CDP discovery: GET ${url} failed (HTTP ${res.status})`); + } + return (await res.json()) as T; +} + +/** + * Open a CDP client against the primary page target on `port`. Suppresses the + * Origin header (Chromium's devtools-target rejects WS upgrades that carry + * one — it's meant for IDE clients, not browser pages). + */ +export async function connectCdp(port: number): Promise<{ + cdp: CDPClient; + wsUrl: string; + target: CdpTarget; +}> { + await ensureCdpReachable(port); + const target = await discoverPrimaryPage(port); + const wsUrl = target.webSocketDebuggerUrl!; + const cdp = new CDPClient(wsUrl, { sendOrigin: false }); + await cdp.connect(); + return { cdp, wsUrl, target }; +} + +/** + * Best-effort domain enables. Failure is non-fatal — most CDP commands work + * without the corresponding domain enabled, but Page.navigate / Input.* return + * more useful errors when their domains are primed. + */ +export async function enableCoreDomains(cdp: CDPClient): Promise { + for (const domain of ["Page", "DOM", "Runtime", "Accessibility"]) { + try { + await cdp.send(`${domain}.enable`); + } catch { + /* ignore */ + } + } +} + +/** Working directory for screenshots / video / clipboard staging. */ +export function mediaDir(): string { + const dir = path.join(os.tmpdir(), "argent-electron-media"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} diff --git a/packages/tool-server/src/electron-server/clipboard.ts b/packages/tool-server/src/electron-server/clipboard.ts new file mode 100644 index 00000000..1c80c639 --- /dev/null +++ b/packages/tool-server/src/electron-server/clipboard.ts @@ -0,0 +1,76 @@ +import type { CDPClient } from "../utils/debugger/cdp-client"; + +/** + * Set the renderer's clipboard text. CDP doesn't expose the OS clipboard + * directly — sim-server has access via NSPasteboard on iOS, but for Electron + * we have to go through the renderer's `navigator.clipboard.writeText`. + * + * That API requires the document to have user-activation focus (Chromium's + * security model), so we first force-focus the page via Page.bringToFront and + * fall back to a document.execCommand("copy") trick if writeText is blocked. + */ +export async function setClipboardText(cdp: CDPClient, text: string): Promise { + try { + await cdp.send("Page.bringToFront"); + } catch { + /* not always available; non-fatal */ + } + + // Encode the text as a JS string literal so embedded quotes/newlines round- + // trip safely through Runtime.evaluate. JSON.stringify is the safest + // serializer here — it covers backslashes, quotes, control chars, unicode. + const literal = JSON.stringify(text); + const script = `(async () => { + const text = ${literal}; + try { + await navigator.clipboard.writeText(text); + return { ok: true }; + } catch (err) { + // Fallback: hidden textarea + document.execCommand("copy"). Works in + // contexts where the Clipboard API is gated on user activation. + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return { ok, error: ok ? null : (err && err.message) || String(err) }; + } + })()`; + const out = (await cdp.send( + "Runtime.evaluate", + { + expression: script, + awaitPromise: true, + returnByValue: true, + }, + 10_000 + )) as { result?: { value?: { ok?: boolean; error?: string } }; exceptionDetails?: unknown }; + const result = out.result?.value; + if (!result?.ok) { + throw new Error( + `Electron clipboard set failed: ${result?.error ?? "renderer rejected the write"}` + ); + } +} + +/** + * Sim-server has bidirectional clipboard sync (OS ↔ device). On Electron the + * direction that matters is "set the renderer's clipboard from a tool call", + * which `setClipboardText` covers. A true sync would require the Electron app + * to opt in via main-process IPC — outside what CDP can offer. This is a + * no-op stub that records the desired state so future native-side coordination + * has a place to hook in. + */ +export class ClipboardSyncState { + private enabled = false; + set(enabled: boolean): void { + this.enabled = enabled; + } + isEnabled(): boolean { + return this.enabled; + } +} diff --git a/packages/tool-server/src/electron-server/fps.ts b/packages/tool-server/src/electron-server/fps.ts new file mode 100644 index 00000000..2d7cde2c --- /dev/null +++ b/packages/tool-server/src/electron-server/fps.ts @@ -0,0 +1,48 @@ +import { TypedEventEmitter } from "@argent/registry"; +import type { ServerEvents } from "./types"; + +const REPORT_INTERVAL_MS = 1000; + +/** + * Tracks screencast frame arrivals and emits `fpsReport` once per second when + * reporting is enabled. Mirrors sim-server's behavior — reporting is opt-in so + * an idle session doesn't generate WS chatter no one cares about. + */ +export class FpsTracker { + private framesInWindow = 0; + private interval: NodeJS.Timeout | null = null; + private enabled = false; + + constructor(private readonly events: TypedEventEmitter) {} + + recordFrame(): void { + this.framesInWindow++; + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + if (enabled) { + this.framesInWindow = 0; + this.interval = setInterval(() => this.flush(), REPORT_INTERVAL_MS); + this.interval.unref?.(); + } else if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + private flush(): void { + const fps = this.framesInWindow; + this.framesInWindow = 0; + this.events.emit("fpsReport", { fps, windowMs: REPORT_INTERVAL_MS }); + } + + dispose(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + this.enabled = false; + } +} diff --git a/packages/tool-server/src/electron-server/http-api.ts b/packages/tool-server/src/electron-server/http-api.ts new file mode 100644 index 00000000..99ded808 --- /dev/null +++ b/packages/tool-server/src/electron-server/http-api.ts @@ -0,0 +1,316 @@ +import { Router, type Request, type Response } from "express"; +import { WebSocketServer, type WebSocket } from "ws"; +import type { IncomingMessage, Server } from "node:http"; +import type { ButtonType, ElectronServer, KeyDirection, Rotation, TouchType } from "./types"; + +/** + * Express router mirroring sim-server's HTTP surface, scoped per Electron + * device id. Mounted under `/electron-server/:id/...` by the tool-server so + * the preview UI / external consumers can drive an Electron app the same way + * they drive a simulator. Endpoints intentionally mirror sim-server names so + * a generic client can be written once. + * + * Routes: + * POST /api/screenshot { rotation?, scale?, downscaler? } → { url, path } + * POST /api/clipboard/text { text } → { status: "ok" } + * POST /api/fps { report: bool } → { status: "ok" } + * POST /api/navigate { url } → { status: "ok" } [extra: not in sim-server] + * POST /api/reload {} → { status: "ok" } [extra] + * POST /api/history/back {} → { moved: bool } [extra] + * POST /api/history/forward {} → { moved: bool } [extra] + * GET /viewport → { width, height, devicePixelRatio } + * + * Plus an MJPEG endpoint `GET /stream.mjpeg` mounted directly by the + * `attachMjpegEndpoint` helper because Express+streaming response bodies are + * awkward without manually flushing. + */ +export function createElectronServerRouter(server: ElectronServer): Router { + const router = Router(); + router.use((req, _res, next) => { + // JSON parsing isn't pre-wired on this sub-router — the tool-server uses + // express.json() at the top level, but make the dependency obvious here so + // the router is reusable outside the tool-server. + if (!req.body) req.body = {}; + next(); + }); + + router.post("/api/screenshot", async (req: Request, res: Response) => { + try { + const body = req.body ?? {}; + const out = await server.captureScreenshot({ + rotation: body.rotation as Rotation | undefined, + scale: typeof body.scale === "number" ? body.scale : undefined, + downscaler: body.downscaler, + id: typeof body.id === "string" ? body.id : undefined, + }); + res.json(out); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.post("/api/clipboard/text", async (req: Request, res: Response) => { + const text = req.body?.text; + if (typeof text !== "string") { + res.status(400).json({ error: "body.text must be a string" }); + return; + } + try { + await server.setClipboardText(text); + res.json({ status: "ok" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.post("/api/fps", (req: Request, res: Response) => { + const report = req.body?.report; + if (typeof report !== "boolean") { + res.status(400).json({ error: "body.report must be a boolean" }); + return; + } + server.setFpsReporting(report); + res.json({ status: "ok" }); + }); + + router.post("/api/navigate", async (req: Request, res: Response) => { + const url = req.body?.url; + if (typeof url !== "string") { + res.status(400).json({ error: "body.url must be a string" }); + return; + } + try { + await server.navigate(url); + res.json({ status: "ok" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.post("/api/reload", async (_req: Request, res: Response) => { + try { + await server.reload(); + res.json({ status: "ok" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.post("/api/history/back", async (_req: Request, res: Response) => { + try { + await server.goBack(); + res.json({ status: "ok" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.post("/api/history/forward", async (_req: Request, res: Response) => { + try { + await server.goForward(); + res.json({ status: "ok" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + router.get("/viewport", (_req: Request, res: Response) => { + res.json(server.getViewport()); + }); + + // MJPEG stream — Chromium's screencast format is already JPEG, so the loop + // is just "subscribe → write multipart body → unsubscribe on close". One + // screencast session is shared across all MJPEG clients via the refcounted + // ScreencastManager. + router.get("/stream.mjpeg", async (req: Request, res: Response) => { + res.status(200); + res.setHeader("Content-Type", "multipart/x-mixed-replace;boundary=NextFrame"); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Access-Control-Allow-Origin", "*"); + + const onFrame = (frame: { data: string }) => { + const jpeg = Buffer.from(frame.data, "base64"); + res.write( + `--NextFrame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n` + ); + res.write(jpeg); + res.write("\r\n"); + }; + server.events.on("frame", onFrame); + + let session: { stop: () => Promise } | null = null; + try { + session = await server.startScreencast({ format: "jpeg", quality: 70 }); + } catch (err) { + server.events.off("frame", onFrame); + res.end(); + return; + } + + const cleanup = async () => { + server.events.off("frame", onFrame); + try { + await session?.stop(); + } catch { + /* ignore */ + } + }; + req.on("close", () => { + cleanup().catch(() => { + /* ignore */ + }); + }); + }); + + return router; +} + +interface WsRequest { + id?: string; + cmd: string; + // Touch + type?: TouchType | string; + x?: number; + y?: number; + second_x?: number | null; + second_y?: number | null; + // Key + direction?: KeyDirection; + code?: number; + key?: string; + text?: string; + codeName?: string; + // Button + button?: ButtonType; + // Rotate + rotation?: Rotation; + // Wheel + dx?: number; + dy?: number; + // Clipboard + enabled?: boolean; +} + +/** + * Attach a WebSocket endpoint at `/ws` for the given Electron server. + * Mirrors sim-server's WS contract: each message is `{ id, cmd, ...payload }` + * and the response is `{ id, status: "ok"|"error", message? }`. Server-pushed + * events (FPS reports) are JSON-encoded with the original event name. + * + * Lives in this file rather than the Express router because Express doesn't + * own the HTTP upgrade handshake — the `ws` library does. + */ +export function attachElectronServerWebsocket( + httpServer: Server, + basePath: string, + resolveServer: (req: IncomingMessage) => ElectronServer | null +): WebSocketServer { + // noServer mode: we handle the upgrade ourselves so we can route by URL. + const wss = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (req, socket, head) => { + const url = req.url ?? ""; + if (!url.startsWith(basePath) || !url.endsWith("/ws")) return; + const server = resolveServer(req); + if (!server) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + bindWsToServer(ws, server); + }); + }); + return wss; +} + +function bindWsToServer(ws: WebSocket, server: ElectronServer): void { + const sendJson = (payload: unknown) => { + try { + ws.send(JSON.stringify(payload)); + } catch { + /* client gone */ + } + }; + const onFps = (report: { fps: number; windowMs: number }) => + sendJson({ event: "fpsReport", ...report }); + const onFrame = (frame: { sessionId: number }) => + sendJson({ event: "frame", sessionId: frame.sessionId }); + server.events.on("fpsReport", onFps); + // Optional: clients subscribed to the WS may not want every frame echoed; we + // only signal the sessionId so they can correlate without paying for base64 + // payloads on this channel. MJPEG clients use the dedicated /stream.mjpeg. + server.events.on("frame", onFrame); + + ws.on("close", () => { + server.events.off("fpsReport", onFps); + server.events.off("frame", onFrame); + }); + + ws.on("message", async (raw) => { + let msg: WsRequest; + try { + msg = JSON.parse(raw.toString()) as WsRequest; + } catch (err) { + sendJson({ status: "error", message: `parse error: ${(err as Error).message}` }); + return; + } + const id = msg.id; + try { + const result = await handleWsCommand(msg, server); + sendJson({ id, status: "ok", ...result }); + } catch (err) { + sendJson({ id, status: "error", message: err instanceof Error ? err.message : String(err) }); + } + }); +} + +async function handleWsCommand( + msg: WsRequest, + server: ElectronServer +): Promise> { + switch (msg.cmd) { + case "touch": { + const touchType = msg.type as TouchType; + const x = msg.x ?? 0; + const y = msg.y ?? 0; + const second = + typeof msg.second_x === "number" && typeof msg.second_y === "number" + ? { x: msg.second_x, y: msg.second_y } + : null; + await server.sendTouch(touchType, { x, y }, second); + return {}; + } + case "key": { + const direction = (msg.direction ?? "Down") as KeyDirection; + await server.sendKey(direction, { + code: typeof msg.code === "number" ? msg.code : undefined, + key: msg.key, + text: msg.text, + codeName: msg.codeName, + }); + return {}; + } + case "button": { + const direction = (msg.direction ?? "Down") as KeyDirection; + await server.sendButton(msg.button as ButtonType, direction); + return {}; + } + case "rotate": { + await server.sendRotate(msg.rotation as Rotation); + return {}; + } + case "wheel": { + await server.sendWheel({ x: msg.x ?? 0, y: msg.y ?? 0 }, msg.dx ?? 0, msg.dy ?? 0); + return {}; + } + case "clipboardSync": { + await server.setClipboardSync(!!msg.enabled); + return {}; + } + default: + throw new Error(`Unknown ws cmd: ${msg.cmd}`); + } +} diff --git a/packages/tool-server/src/electron-server/index.ts b/packages/tool-server/src/electron-server/index.ts new file mode 100644 index 00000000..e02e9957 --- /dev/null +++ b/packages/tool-server/src/electron-server/index.ts @@ -0,0 +1,167 @@ +import { TypedEventEmitter } from "@argent/registry"; +import type { CDPClient } from "../utils/debugger/cdp-client"; +import { connectCdp, enableCoreDomains } from "./cdp-session"; +import { ClipboardSyncState, setClipboardText } from "./clipboard"; +import { FpsTracker } from "./fps"; +import { sendButton, sendKey, sendRotate, sendTouch, sendWheel, sendCharInsert } from "./input"; +import { goBack, goForward, navigate, reload } from "./navigation"; +import { ScreencastManager } from "./screencast"; +import { captureScreenshot, copyScreenshotToClipboard } from "./screenshot"; +import type { + ButtonType, + ElectronServer, + KeyDirection, + MediaReady, + Point, + Rotation, + ScreencastFrame, + ScreencastOpts, + ScreencastSession, + ScreenshotOpts, + ServerEvents, + TouchType, + ViewportSize, +} from "./types"; +import { readViewport } from "./viewport"; + +export type { + ButtonType, + DownscalerType, + ElectronServer, + FpsReport, + KeyDirection, + MediaReady, + Point, + Rotation, + ScreencastFrame, + ScreencastOpts, + ScreencastSession, + ScreenshotOpts, + ServerEvents, + TouchType, + ViewportSize, +} from "./types"; + +export { sendCharInsert } from "./input"; + +export interface CreateElectronServerOpts { + /** Argent device id, used for screenshot filename prefix + diagnostics. */ + deviceId: string; + /** CDP port the Electron process exposed via --remote-debugging-port. */ + port: number; +} + +/** + * Compose the per-device ElectronServer. Connects CDP, primes core domains, + * reads the initial viewport, and wires every subsystem (input, screenshot, + * screencast, fps, clipboard, navigation, events) onto one CDP session. + * + * The returned `dispose()` tears down screencast first, then disconnects CDP — + * leaving an active screencast running would emit phantom frame events after + * the consumer dropped its ref. + */ +export async function createElectronServer( + opts: CreateElectronServerOpts +): Promise { + const { cdp, wsUrl } = await connectCdp(opts.port); + await enableCoreDomains(cdp); + + let viewport: ViewportSize = await readViewport(cdp); + const events = new TypedEventEmitter(); + const fps = new FpsTracker(events); + const screencast = new ScreencastManager(cdp, events, fps); + const clipboardSync = new ClipboardSyncState(); + + cdp.events.on("disconnected", (err) => { + events.emit("terminated", err ?? new Error(`Electron CDP on port ${opts.port} disconnected`)); + }); + + const server: ElectronServer = { + port: opts.port, + cdp, + pageWebSocketUrl: wsUrl, + getViewport: () => viewport, + refreshViewport: async () => { + viewport = await readViewport(cdp); + return viewport; + }, + captureScreenshot: (opts2?: ScreenshotOpts) => + captureScreenshot({ cdp, deviceId: opts.deviceId }, opts2), + copyScreenshotToClipboard: (opts2?: { rotation?: Rotation }) => + copyScreenshotToClipboard({ cdp, deviceId: opts.deviceId }, opts2), + sendTouch: (touchType: TouchType, point: Point, secondPoint?: Point | null) => + sendTouch(cdp, viewport, touchType, point, secondPoint), + sendKey: (direction, key) => sendKey(cdp, direction, key), + sendButton: (button: ButtonType, direction: KeyDirection) => sendButton(cdp, button, direction), + sendRotate: (direction: Rotation) => sendRotate(cdp, viewport, direction), + sendWheel: (point: Point, dx: number, dy: number) => sendWheel(cdp, viewport, point, dx, dy), + setClipboardSync: async (enabled: boolean) => { + // No native bridge today; record intent so a future Electron-side helper + // can wire it up. We still resolve so callers don't have to special-case + // the not-yet-implemented path. + clipboardSync.set(enabled); + }, + setClipboardText: (text: string) => setClipboardText(cdp, text), + startScreencast: (opts2?: ScreencastOpts): Promise => + screencast.start(opts2), + getLastFrame: (): ScreencastFrame | null => screencast.getLastFrame(), + navigate: async (url: string) => { + await navigate(cdp, url); + // Refresh the cached viewport — a route swap can change layout dimensions + // (responsive UIs, full-screen modal pages). + try { + viewport = await readViewport(cdp); + } catch { + /* viewport read can race a still-loading page; leave the cached one */ + } + }, + reload: () => reload(cdp), + goBack: async () => { + await goBack(cdp); + }, + goForward: async () => { + await goForward(cdp); + }, + setFpsReporting: (enabled: boolean) => fps.setEnabled(enabled), + evaluate: async ( + expression: string, + options?: { returnByValue?: boolean } + ): Promise => { + if (options?.returnByValue) { + const out = (await cdp.send( + "Runtime.evaluate", + { expression, returnByValue: true }, + 10_000 + )) as { result?: { value?: unknown } }; + return out.result?.value; + } + return cdp.evaluate(expression, { timeout: 10_000 }); + }, + events, + dispose: async () => { + try { + await screencast.forceStop(); + } catch { + /* ignore */ + } + fps.dispose(); + try { + await cdp.disconnect(); + } catch { + /* ignore */ + } + }, + }; + return server; +} + +// Re-exported for use from the blueprint when we need a low-level CDP handle. +export { ensureCdpReachable, discoverPrimaryPage } from "./cdp-session"; + +// Re-exported so the http-api / blueprint can call them directly without +// pulling them out of an ElectronServer instance. +export { setClipboardText } from "./clipboard"; + +// Internal re-export so tests can stub these without going through the full factory. +export type { CDPClient }; +export { sendCharInsert as __sendCharInsert }; diff --git a/packages/tool-server/src/electron-server/input.ts b/packages/tool-server/src/electron-server/input.ts new file mode 100644 index 00000000..88e9e305 --- /dev/null +++ b/packages/tool-server/src/electron-server/input.ts @@ -0,0 +1,229 @@ +import type { CDPClient } from "../utils/debugger/cdp-client"; +import type { ButtonType, KeyDirection, Point, Rotation, TouchType, ViewportSize } from "./types"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function clampPx(value: number, max: number): number { + if (!Number.isFinite(value)) { + throw new Error(`Electron input: non-finite coordinate ${value}`); + } + return Math.max(0, Math.min(max, value)); +} + +function toCssPixels(point: Point, viewport: ViewportSize): { x: number; y: number } { + return { + x: clampPx(point.x * viewport.width, viewport.width), + y: clampPx(point.y * viewport.height, viewport.height), + }; +} + +/** + * Forward a sim-server-style touch into a CDP mouse event. Single-touch is + * trivial; multi-touch (secondPoint) maps to `Input.dispatchTouchEvent` which + * Chromium supports for emulated mobile. + */ +export async function sendTouch( + cdp: CDPClient, + viewport: ViewportSize, + touchType: TouchType, + point: Point, + secondPoint?: Point | null +): Promise { + const primary = toCssPixels(point, viewport); + + if (secondPoint) { + const secondary = toCssPixels(secondPoint, viewport); + const touchPoints = [ + { x: primary.x, y: primary.y, id: 1 }, + { x: secondary.x, y: secondary.y, id: 2 }, + ]; + const type = + touchType === "Down" ? "touchStart" : touchType === "Up" ? "touchEnd" : "touchMove"; + await cdp.send("Input.dispatchTouchEvent", { + type, + touchPoints: type === "touchEnd" ? [] : touchPoints, + }); + return; + } + + const cdpType = + touchType === "Down" ? "mousePressed" : touchType === "Up" ? "mouseReleased" : "mouseMoved"; + const button = cdpType === "mouseMoved" ? "none" : "left"; + const buttons = button === "none" ? 0 : 1; + const payload: Record = { + type: cdpType, + x: primary.x, + y: primary.y, + button, + buttons, + }; + if (cdpType !== "mouseMoved") { + payload.clickCount = 1; + } + await cdp.send("Input.dispatchMouseEvent", payload); +} + +/** + * Forward a key event. The contract is union-style: callers can pass a + * USB HID code (matches the sim-server protocol) OR a browser-style descriptor + * (`key`, `text`, `codeName`). Most callers in the tool-server use the + * browser-style fields because translating HID → DOM keycodes is lossy. + */ +export async function sendKey( + cdp: CDPClient, + direction: KeyDirection, + desc: { code?: number; key?: string; text?: string; codeName?: string } +): Promise { + const type = direction === "Down" ? "keyDown" : "keyUp"; + const payload: Record = { type }; + if (desc.key !== undefined) payload.key = desc.key; + if (desc.codeName !== undefined) payload.code = desc.codeName; + if (desc.text !== undefined) payload.text = desc.text; + if (desc.code !== undefined) payload.windowsVirtualKeyCode = desc.code; + await cdp.send("Input.dispatchKeyEvent", payload); +} + +/** + * Send a CDP key event AND, when typing a printable character with `Down`, + * also dispatch a `char` event so the renderer actually receives the + * codepoint in focused inputs. Sim-server callers expect typing to "just + * work"; the bare keyDown alone doesn't insert text in modern Chromium. + */ +export async function sendCharInsert(cdp: CDPClient, text: string): Promise { + await cdp.send("Input.dispatchKeyEvent", { type: "char", text }); +} + +const BUTTON_TO_KEY: Record = { + // Hardware buttons don't map cleanly to a desktop renderer. We do best-effort + // for the ones that *do* have an analogue, and reject the rest with a clear + // error so callers don't think they worked. + Home: null, // No browser equivalent — would need main-process ipc. + Back: { key: "Alt", codeName: "AltLeft", vk: 18 }, // Will be paired with ArrowLeft below. + Power: null, + VolumeUp: null, + VolumeDown: null, + AppSwitch: null, + ActionButton: null, +}; + +/** + * Best-effort hardware-button translation. Only `Back` has a sane + * desktop-renderer equivalent (Alt+Left to walk navigation history). The + * others throw; callers that rely on them on Electron should switch to a + * dedicated tool. + */ +export async function sendButton( + cdp: CDPClient, + button: ButtonType, + direction: KeyDirection +): Promise { + if (button === "Back") { + // Single keystroke composite: Alt+Left. We only send the modified key on + // Down and the release on Up to match sim-server's two-phase contract. + if (direction === "Down") { + await cdp.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Alt", + code: "AltLeft", + windowsVirtualKeyCode: 18, + modifiers: 1, + }); + await cdp.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "ArrowLeft", + code: "ArrowLeft", + windowsVirtualKeyCode: 37, + modifiers: 1, + }); + } else { + await cdp.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "ArrowLeft", + code: "ArrowLeft", + windowsVirtualKeyCode: 37, + modifiers: 1, + }); + await cdp.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Alt", + code: "AltLeft", + windowsVirtualKeyCode: 18, + }); + } + return; + } + throw new Error( + `Electron does not support the "${button}" hardware button. ` + + `Use a keyboard shortcut via the keyboard tool, or invoke an app-level handler via the debugger.` + ); +} + +/** + * Wheel scroll at a point. CDP's mouseWheel event accepts deltaX / deltaY in + * CSS pixels. We forward as a single event — sim-server's multi-step ramp is + * only useful for native gesture simulation, which Chromium doesn't expose. + */ +export async function sendWheel( + cdp: CDPClient, + viewport: ViewportSize, + point: Point, + dx: number, + dy: number +): Promise { + if (!Number.isFinite(dx) || !Number.isFinite(dy)) { + throw new Error(`Electron wheel: non-finite delta dx=${dx}, dy=${dy}`); + } + if (dx === 0 && dy === 0) return; + const pixel = toCssPixels(point, viewport); + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: pixel.x, + y: pixel.y, + button: "none", + buttons: 0, + deltaX: dx, + deltaY: dy, + }); +} + +/** + * Rotate the viewport via Emulation.setDeviceMetricsOverride. Chromium only + * supports rotation values of 0 / 90 / 180 / 270, applied as a CSS transform + * around the page centre. We persist the current rotation so it can be + * read back via getRotation(). + */ +const ROTATION_DEGREES: Record = { + Portrait: 0, + LandscapeLeft: 270, + LandscapeRight: 90, + PortraitUpsideDown: 180, +}; + +export async function sendRotate( + cdp: CDPClient, + viewport: ViewportSize, + direction: Rotation +): Promise { + const angle = ROTATION_DEGREES[direction]; + await cdp.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: viewport.devicePixelRatio, + mobile: false, + screenOrientation: { + type: + direction === "Portrait" + ? "portraitPrimary" + : direction === "PortraitUpsideDown" + ? "portraitSecondary" + : direction === "LandscapeLeft" + ? "landscapeSecondary" + : "landscapePrimary", + angle, + }, + }); +} + +// Re-exported for tests + downstream callers that want to convert without +// duplicating the math. +export const __test = { toCssPixels, clampPx, sleep }; diff --git a/packages/tool-server/src/electron-server/navigation.ts b/packages/tool-server/src/electron-server/navigation.ts new file mode 100644 index 00000000..b91927e0 --- /dev/null +++ b/packages/tool-server/src/electron-server/navigation.ts @@ -0,0 +1,44 @@ +import type { CDPClient } from "../utils/debugger/cdp-client"; + +interface NavigationHistory { + currentIndex: number; + entries: Array<{ id: number; url: string; title: string }>; +} + +export async function navigate(cdp: CDPClient, url: string): Promise { + // Page.navigate accepts about:blank, file://, data:, http(s):, etc. + // No URL whitelist here — the caller is the tool-server, which already + // validated the URL against the tool's zod schema. + await cdp.send("Page.navigate", { url }); +} + +export async function reload(cdp: CDPClient, ignoreCache = false): Promise { + await cdp.send("Page.reload", { ignoreCache }); +} + +async function getHistory(cdp: CDPClient): Promise { + return (await cdp.send("Page.getNavigationHistory", {})) as NavigationHistory; +} + +/** + * Walk one step back in the renderer's navigation history. Returns false + * when already at the oldest entry — matches browser `history.back()` no-op + * semantics instead of throwing. + */ +export async function goBack(cdp: CDPClient): Promise { + const history = await getHistory(cdp); + if (history.currentIndex <= 0) return false; + const target = history.entries[history.currentIndex - 1]; + if (!target) return false; + await cdp.send("Page.navigateToHistoryEntry", { entryId: target.id }); + return true; +} + +export async function goForward(cdp: CDPClient): Promise { + const history = await getHistory(cdp); + if (history.currentIndex >= history.entries.length - 1) return false; + const target = history.entries[history.currentIndex + 1]; + if (!target) return false; + await cdp.send("Page.navigateToHistoryEntry", { entryId: target.id }); + return true; +} diff --git a/packages/tool-server/src/electron-server/screencast.ts b/packages/tool-server/src/electron-server/screencast.ts new file mode 100644 index 00000000..eedf330a --- /dev/null +++ b/packages/tool-server/src/electron-server/screencast.ts @@ -0,0 +1,129 @@ +import type { TypedEventEmitter } from "@argent/registry"; +import type { CDPClient } from "../utils/debugger/cdp-client"; +import type { FpsTracker } from "./fps"; +import type { ScreencastFrame, ScreencastOpts, ScreencastSession, ServerEvents } from "./types"; + +/** + * Manages a single Chromium screencast session per Electron device, with + * refcounted consumers. Sim-server's MJPEG service spins up a JPEG encoder + * only while at least one client is connected; we do the same — `start()` is + * idempotent for additional callers and only triggers `Page.startScreencast` + * on the transition from 0 → 1 active subscriber. `Page.stopScreencast` fires + * once the last subscriber drops. + * + * The wrapping `ScreencastSession.stop()` is the disposal handle each caller + * holds; calling it twice is safe. + */ +export class ScreencastManager { + private activeCount = 0; + private currentOpts: ScreencastOpts | null = null; + private lastFrame: ScreencastFrame | null = null; + private cdpListenerInstalled = false; + + constructor( + private readonly cdp: CDPClient, + private readonly events: TypedEventEmitter, + private readonly fps: FpsTracker + ) {} + + /** + * Most-recently-received frame, or null if no screencast is active / + * Chromium hasn't pushed a frame yet. Exposed so single-shot consumers + * (preview overlay, snapshot debug tool) can grab the last frame without + * starting their own session. + */ + getLastFrame(): ScreencastFrame | null { + return this.lastFrame; + } + + async start(opts: ScreencastOpts = {}): Promise { + this.installCdpListenerOnce(); + + this.activeCount += 1; + if (this.activeCount === 1) { + this.currentOpts = opts; + await this.cdp.send("Page.startScreencast", this.toCdpStartArgs(opts)); + } else if (this.optsDiffer(opts, this.currentOpts)) { + // Subsequent callers join the existing session and accept whatever + // format / quality / size the first caller chose. Forcing a restart + // would tear down the first caller's stream mid-frame. + process.stderr.write( + `[electron-screencast] additional caller requested screencast opts that differ from the active session; ignoring (first writer wins).\n` + ); + } + + let stopped = false; + const session: ScreencastSession = { + stop: async () => { + if (stopped) return; + stopped = true; + this.activeCount = Math.max(0, this.activeCount - 1); + if (this.activeCount === 0) { + await this.cdp.send("Page.stopScreencast").catch(() => { + /* the session may already be torn down on disconnect */ + }); + this.currentOpts = null; + } + }, + }; + return session; + } + + /** Force-stop screencast regardless of refcount. Called on dispose. */ + async forceStop(): Promise { + this.activeCount = 0; + this.currentOpts = null; + await this.cdp.send("Page.stopScreencast").catch(() => { + /* ignore */ + }); + } + + private installCdpListenerOnce(): void { + if (this.cdpListenerInstalled) return; + this.cdpListenerInstalled = true; + this.cdp.events.on("event", (method, params) => { + if (method !== "Page.screencastFrame") return; + const payload = params as { + sessionId: number; + data: string; + metadata: ScreencastFrame["metadata"]; + }; + const frame: ScreencastFrame = { + sessionId: payload.sessionId, + data: payload.data, + metadata: payload.metadata, + }; + this.lastFrame = frame; + this.fps.recordFrame(); + this.events.emit("frame", frame); + // Chromium pauses the screencast until every emitted frame is ack'd — + // missing an ack manifests as a frozen stream. Fire-and-forget is fine + // because send() returns a promise we don't need to await. + this.cdp.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => { + /* ignore — session may have closed between emit and ack */ + }); + }); + } + + private toCdpStartArgs(opts: ScreencastOpts): Record { + const args: Record = { + format: opts.format ?? "jpeg", + everyNthFrame: opts.everyNthFrame ?? 1, + }; + if (opts.quality !== undefined) args.quality = opts.quality; + if (opts.maxWidth !== undefined) args.maxWidth = opts.maxWidth; + if (opts.maxHeight !== undefined) args.maxHeight = opts.maxHeight; + return args; + } + + private optsDiffer(a: ScreencastOpts, b: ScreencastOpts | null): boolean { + if (!b) return true; + return ( + (a.format ?? "jpeg") !== (b.format ?? "jpeg") || + (a.quality ?? null) !== (b.quality ?? null) || + (a.maxWidth ?? null) !== (b.maxWidth ?? null) || + (a.maxHeight ?? null) !== (b.maxHeight ?? null) || + (a.everyNthFrame ?? 1) !== (b.everyNthFrame ?? 1) + ); + } +} diff --git a/packages/tool-server/src/electron-server/screenshot.ts b/packages/tool-server/src/electron-server/screenshot.ts new file mode 100644 index 00000000..95fc5097 --- /dev/null +++ b/packages/tool-server/src/electron-server/screenshot.ts @@ -0,0 +1,206 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { CDPClient } from "../utils/debugger/cdp-client"; +import { mediaDir } from "./cdp-session"; +import type { DownscalerType, MediaReady, Rotation, ScreenshotOpts } from "./types"; + +interface SharpModule { + (input: Buffer): { + rotate(angle: number): ReturnType; + resize( + width: number, + height: number, + opts: { kernel?: string; fit?: string } + ): ReturnType; + png(opts?: { compressionLevel?: number }): ReturnType; + toBuffer(): Promise; + }; +} + +let sharpCache: SharpModule | null | undefined; +let sharpLoadWarningEmitted = false; + +/** + * Try to load `sharp` once per process. It is an optional dependency — we + * fall back to writing the raw CDP screenshot bytes when it's missing, and + * emit one warning so the user knows scale / rotation were ignored. Adding + * sharp as a hard dep would bloat the tool-server install with a ~30 MB + * native binary every consumer pays for whether they touch Electron or not. + */ +function tryLoadSharp(): SharpModule | null { + if (sharpCache !== undefined) return sharpCache; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("sharp") as SharpModule; + sharpCache = mod; + return mod; + } catch { + sharpCache = null; + return null; + } +} + +function warnSharpMissingOnce(reason: string): void { + if (sharpLoadWarningEmitted) return; + sharpLoadWarningEmitted = true; + process.stderr.write( + `[electron-screenshot] sharp is not installed — ${reason} ignored. ` + + `Install it with \`npm install sharp\` in the tool-server's environment to enable image post-processing.\n` + ); +} + +const DOWNSCALER_TO_KERNEL: Record = { + lanczos3: "lanczos3", + box: "mitchell", // sharp doesn't expose a true box kernel; mitchell is the closest fast alternative + bilinear: "lanczos2", + nearest: "nearest", +}; + +const ROTATION_DEGREES: Record = { + Portrait: 0, + PortraitUpsideDown: 180, + LandscapeLeft: 270, + LandscapeRight: 90, +}; + +interface CaptureContext { + cdp: CDPClient; + /** Used in the persisted filename for traceability. */ + deviceId: string; +} + +/** + * One-shot capture pipeline: + * Page.captureScreenshot (PNG) + * ↓ + * if rotation || scale<1 then sharp transform + * ↓ + * write to mediaDir / argent-screenshot--.png + * + * Returns { url: file://…, path } matching sim-server's MediaReady shape. + */ +export async function captureScreenshot( + ctx: CaptureContext, + opts: ScreenshotOpts = {} +): Promise { + // Always use PNG from CDP — JPEG would lose precision on downscale and + // disagrees with sim-server's screenshot output format (also PNG). + const cdpResult = (await ctx.cdp.send("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + })) as { data?: string }; + if (!cdpResult.data) { + throw new Error("Electron CDP: Page.captureScreenshot returned no data."); + } + let bytes = Buffer.from(cdpResult.data, "base64"); + + const rotation = opts.rotation && opts.rotation !== "Portrait" ? opts.rotation : null; + const scale = opts.scale != null && opts.scale > 0 && opts.scale < 1 ? opts.scale : null; + + if (rotation || scale) { + const sharp = tryLoadSharp(); + if (!sharp) { + const features = [rotation && "rotation", scale && "scale"].filter(Boolean).join(" + "); + warnSharpMissingOnce(features); + } else { + let pipeline = sharp(bytes); + if (rotation) pipeline = pipeline.rotate(ROTATION_DEGREES[rotation]); + if (scale) { + // Need the source dimensions to compute the target size in pixels. + // sharp exposes them through `.metadata()` but that's an extra round + // trip; reading the PNG header is faster and avoids a sharp call. + const dims = readPngSize(bytes); + if (dims) { + const targetW = Math.max(1, Math.round(dims.width * scale)); + const targetH = Math.max(1, Math.round(dims.height * scale)); + pipeline = pipeline.resize(targetW, targetH, { + kernel: DOWNSCALER_TO_KERNEL[opts.downscaler ?? "lanczos3"], + fit: "fill", + }); + } + } + // The newer @types/node strictly types `Buffer` while + // sharp's d.ts still resolves to `Buffer`. Both refer to + // the same runtime object; coerce to silence the mismatch. + bytes = Buffer.from(await pipeline.png({ compressionLevel: 6 }).toBuffer()); + } + } + + const stem = opts.id ?? `${Date.now()}-${process.pid}`; + const safeDeviceId = ctx.deviceId.replace(/[^A-Za-z0-9_-]/g, "_"); + const filePath = path.join(mediaDir(), `argent-screenshot-${safeDeviceId}-${stem}.png`); + fs.writeFileSync(filePath, bytes); + return { url: `file://${filePath}`, path: filePath }; +} + +/** + * Read width / height from a PNG IHDR chunk without spinning up a decoder. + * Returns null on a malformed or non-PNG buffer — the caller falls back to + * sharp metadata in that case (which costs a roundtrip but always works). + */ +function readPngSize(buf: Buffer): { width: number; height: number } | null { + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (buf.length < 24) return null; + for (let i = 0; i < signature.length; i++) { + if (buf[i] !== signature[i]) return null; + } + // IHDR chunk: length (4) + "IHDR" (4) + width (4) + height (4) + ... + // Starts at offset 8, header data is at offset 16. + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +/** + * Sim-server can also copy a screenshot directly to the OS clipboard (handy + * for "share this state with QA"). On Electron we go through the renderer's + * Clipboard API because CDP doesn't expose the OS clipboard. The path is + * best-effort — if clipboard permission is denied the call rejects with the + * underlying renderer error so callers can surface it. + */ +export async function copyScreenshotToClipboard( + ctx: CaptureContext, + opts: { rotation?: Rotation } = {} +): Promise { + const shot = await captureScreenshot(ctx, { rotation: opts.rotation }); + const bytes = fs.readFileSync(shot.path); + const b64 = bytes.toString("base64"); + + // Build a script that copies a PNG blob through the clipboard API. The + // renderer must support ClipboardItem (Chromium ≥ 79, every Electron we + // care about) — older runtimes would throw, but the surrounding try/catch + // surfaces that as a clear error rather than a silent no-op. + const script = `(async () => { + const b64 = "${b64}"; + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const blob = new Blob([bytes], { type: "image/png" }); + if (!window.ClipboardItem) { + return { ok: false, error: "ClipboardItem API unavailable in this renderer" }; + } + try { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + return { ok: true }; + } catch (err) { + return { ok: false, error: (err && err.message) || String(err) }; + } + })()`; + const out = (await ctx.cdp.send( + "Runtime.evaluate", + { expression: script, awaitPromise: true, returnByValue: true }, + 10_000 + )) as { result?: { value?: { ok?: boolean; error?: string } } }; + const v = out.result?.value; + if (!v?.ok) { + throw new Error( + `Electron clipboard image copy failed: ${v?.error ?? "renderer rejected the write"}` + ); + } +} + +/** Test seam: reset the cached sharp module so a unit test can simulate the + * "sharp unavailable" path without monkey-patching require. */ +export function __resetSharpCacheForTests(): void { + sharpCache = undefined; + sharpLoadWarningEmitted = false; +} diff --git a/packages/tool-server/src/electron-server/types.ts b/packages/tool-server/src/electron-server/types.ts new file mode 100644 index 00000000..f2b38545 --- /dev/null +++ b/packages/tool-server/src/electron-server/types.ts @@ -0,0 +1,176 @@ +/** + * Shared types for the Electron server abstraction layer. Mirrors the + * sim-server's domain model (Touch / Button / Rotate / Wheel) so callers can be + * written against a single conceptual surface regardless of platform — the + * adapter layer translates them into CDP wire payloads. + */ + +import type { TypedEventEmitter } from "@argent/registry"; +import type { CDPClient } from "../utils/debugger/cdp-client"; + +export type TouchType = "Down" | "Up" | "Move"; +export type KeyDirection = "Down" | "Up"; + +/** Sim-server's hardware buttons. iOS/Android map to OS-level events; on + * Electron most are inert — only ones that correspond to keyboard chords (Home, + * Back via browser-history, AppSwitch ≈ Cmd-Tab) are best-effort implemented. + */ +export type ButtonType = + | "Home" + | "Back" + | "Power" + | "VolumeUp" + | "VolumeDown" + | "AppSwitch" + | "ActionButton"; + +export type Rotation = "Portrait" | "PortraitUpsideDown" | "LandscapeLeft" | "LandscapeRight"; + +/** Sim-server-style normalized point: `x` and `y` in 0.0–1.0, fractions of the + * device screen / page viewport. Pixel conversion happens inside each adapter. */ +export interface Point { + x: number; + y: number; +} + +/** Downscaler choice. Mirrors sim-server's wire enum so callers don't need to + * relearn names. `lanczos3` is the highest-quality option, `nearest` the + * cheapest. All variants degrade to a no-op if the optional `sharp` dep isn't + * installed (see screenshot.ts). */ +export type DownscalerType = "lanczos3" | "box" | "bilinear" | "nearest"; + +export interface ScreenshotOpts { + /** Optional rotation applied AFTER capture (CSS pixels). */ + rotation?: Rotation; + /** Scale factor in (0, 1]. <1 downscales the PNG before writing to disk. */ + scale?: number; + /** Algorithm used when `scale < 1`. Ignored otherwise. */ + downscaler?: DownscalerType; + /** Output filename stem (without extension). When omitted, a timestamp is used. */ + id?: string; +} + +export interface MediaReady { + /** file:// URL that resolves to `path`. Tools surface this to agents. */ + url: string; + /** Absolute path on the tool-server host. */ + path: string; +} + +export interface ViewportSize { + width: number; + height: number; + /** Renderer-reported DPR; used to convert CDP screencast frame px → viewport px. */ + devicePixelRatio: number; +} + +export interface ScreencastOpts { + /** "jpeg" is what every consumer expects; PNG is supported by CDP but + * inflates frame size 5–10× and saturates the WebSocket. */ + format?: "jpeg" | "png"; + /** JPEG quality, 0–100. Ignored for PNG. */ + quality?: number; + /** Optional max frame width; CDP scales the image proportionally. */ + maxWidth?: number; + /** Optional max frame height. */ + maxHeight?: number; + /** Send one frame per N rendered frames. Default 1. */ + everyNthFrame?: number; +} + +export interface ScreencastFrame { + /** Sequential frame id, used for the screencast ack. */ + sessionId: number; + /** Base64-encoded image bytes. */ + data: string; + /** Metadata reported by CDP — viewport offset, scale, timestamp. */ + metadata: { + offsetTop: number; + pageScaleFactor: number; + deviceWidth: number; + deviceHeight: number; + scrollOffsetX: number; + scrollOffsetY: number; + timestamp?: number; + }; +} + +export interface ScreencastSession { + /** Disposes the screencast — stops CDP screencast emission and removes listeners. */ + stop(): Promise; +} + +export interface FpsReport { + /** Frames received in the last interval. */ + fps: number; + /** Window size in ms. */ + windowMs: number; +} + +export type ServerEvents = { + /** Each emitted screencast frame is forwarded here so multiple consumers + * (MJPEG endpoint + internal listeners) share one CDP screencast session. */ + frame: (frame: ScreencastFrame) => void; + /** Periodic FPS report when reporting is enabled. */ + fpsReport: (report: FpsReport) => void; + /** Terminated by CDP disconnect; consumers should drop their refs. */ + terminated: (error?: Error) => void; +}; + +/** + * Public Electron-server contract. The blueprint resolves an instance per + * device id; tools and HTTP routers consume it. + */ +export interface ElectronServer { + /** CDP port the Electron process is exposing (extracted from device.id). */ + readonly port: number; + /** Underlying CDP client connected to the primary page target. */ + readonly cdp: CDPClient; + /** ws:// URL to the page target — handy for diagnostics. */ + readonly pageWebSocketUrl: string; + /** Cached viewport from the most recent connect / refresh. */ + getViewport(): ViewportSize; + /** Re-read viewport from the renderer. Call after window resize / nav. */ + refreshViewport(): Promise; + /** Capture + (optionally) rotate + downscale + persist a PNG. */ + captureScreenshot(opts?: ScreenshotOpts): Promise; + /** Copy the most recent or freshly-captured frame to the OS clipboard as an image. */ + copyScreenshotToClipboard(opts?: { rotation?: Rotation }): Promise; + /** Touch event. `point` is normalized 0–1. `secondPoint` is for multi-touch. */ + sendTouch(touchType: TouchType, point: Point, secondPoint?: Point | null): Promise; + /** Press / release a single key (USB HID code or browser-style key). */ + sendKey( + direction: KeyDirection, + key: { code?: number; key?: string; text?: string; codeName?: string } + ): Promise; + /** Hardware button. Best-effort on Electron; throws "not supported" for + * buttons with no browser equivalent. */ + sendButton(button: ButtonType, direction: KeyDirection): Promise; + /** Rotate the viewport. Uses Emulation.setDeviceMetricsOverride. */ + sendRotate(direction: Rotation): Promise; + /** Wheel scroll at a point. dx/dy are CSS pixels. */ + sendWheel(point: Point, dx: number, dy: number): Promise; + /** Subscribe to OS clipboard → page bridge. No-op stub for now. */ + setClipboardSync(enabled: boolean): Promise; + /** Programmatically set the renderer's clipboard text via DOM APIs. */ + setClipboardText(text: string): Promise; + /** Start a CDP screencast. Frames are forwarded to `events.on("frame", ...)` + * and to any consumer subscribed via `onFrame`. Multiple callers share one + * CDP session via internal refcounting. */ + startScreencast(opts?: ScreencastOpts): Promise; + /** Returns the most recently received screencast frame, if any. */ + getLastFrame(): ScreencastFrame | null; + /** Navigate the renderer. */ + navigate(url: string): Promise; + reload(): Promise; + goBack(): Promise; + goForward(): Promise; + /** Enable / disable periodic `fpsReport` emissions on `events`. */ + setFpsReporting(enabled: boolean): void; + /** Evaluate JS in the renderer's main world. */ + evaluate(expression: string, options?: { returnByValue?: boolean }): Promise; + /** Event bus mirroring sim-server's broadcast channel. */ + readonly events: TypedEventEmitter; + /** Tear down — closes CDP, stops screencast, removes listeners. */ + dispose(): Promise; +} diff --git a/packages/tool-server/src/electron-server/viewport.ts b/packages/tool-server/src/electron-server/viewport.ts new file mode 100644 index 00000000..3eb57202 --- /dev/null +++ b/packages/tool-server/src/electron-server/viewport.ts @@ -0,0 +1,35 @@ +import type { CDPClient } from "../utils/debugger/cdp-client"; +import type { ViewportSize } from "./types"; + +/** + * Read window.innerWidth/Height/devicePixelRatio from the renderer's main + * world. Throws when the call returns nothing — silently substituting a fake + * 800×600 would corrupt every subsequent tap's normalized → CSS-pixel math. + */ +export async function readViewport(cdp: CDPClient): Promise { + const out = (await cdp.send("Runtime.evaluate", { + expression: + "JSON.stringify({ w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio || 1 })", + returnByValue: true, + })) as { result?: { value?: string } }; + const raw = out.result?.value; + if (typeof raw !== "string") { + throw new Error( + "Electron CDP: Runtime.evaluate for viewport returned no value. The renderer may be navigating or its main world is detached." + ); + } + let parsed: { w: number; h: number; dpr: number }; + try { + parsed = JSON.parse(raw) as { w: number; h: number; dpr: number }; + } catch (err) { + throw new Error( + `Electron CDP: viewport payload was not JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (!parsed.w || !parsed.h) { + throw new Error( + `Electron CDP: viewport reported zero dimensions (w=${parsed.w}, h=${parsed.h}). The BrowserWindow may be hidden.` + ); + } + return { width: parsed.w, height: parsed.h, devicePixelRatio: parsed.dpr || 1 }; +} diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 0b0d716d..7277b8ed 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -15,6 +15,17 @@ import { UnsupportedOperationError, } from "./utils/capability"; import { resolveDevice } from "./utils/device-info"; +import type { Server as HttpServer } from "node:http"; +import { + ELECTRON_CDP_NAMESPACE, + electronCdpRef, + type ElectronCdpApi, +} from "./blueprints/electron-cdp"; +import { + attachElectronServerWebsocket, + createElectronServerRouter, +} from "./electron-server/http-api"; +import { resolveDevice as resolveDeviceForWs } from "./utils/device-info"; const AUTO_SUPPRESS_MS = 30 * 60 * 1000; // 30 minutes @@ -73,6 +84,12 @@ export interface HttpAppHandle { dispose: () => void; /** Timestamp of the last tool invocation (ms since epoch). Exposed for testing. */ getLastActivityAt: () => number; + /** Attach the per-Electron-device WebSocket upgrade handler to the live + * http.Server. Called once `app.listen()` has been invoked and the server + * is bound. Splitting this out from `createHttpApp` keeps construction + * synchronous — the WS upgrade is the only part that needs the Node server + * instance rather than the Express app. */ + attachElectronWebsockets: (server: HttpServer) => void; } // Loopback hostnames the browser is allowed to address us by. The @@ -203,6 +220,42 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt // them via TOOLS_URL instead of an unreachable 127.0.0.1 host path/URL. app.get("/artifacts/:id", makeArtifactRoute(registry)); + // Per-Electron-device HTTP surface that mirrors sim-server's API: a + // `/electron-server/:id/api/*` namespace plus `/stream.mjpeg` and `/viewport`. + // The router is mounted lazily — the first request for a given id resolves + // the registry service (kicking off the CDP connection) and then forwards + // every subsequent request to that already-warm session. Like /preview, this + // surface is NOT advertised to MCP agents; tools remain the canonical way to + // drive Electron from an LLM. The HTTP surface is for non-agent consumers + // (preview UI, integration tests, custom dashboards). + app.use("/electron-server/:deviceId", async (req: Request, res: Response, next) => { + idleTimer.touch(); + const deviceId = req.params.deviceId!; + const device = resolveDevice(deviceId); + if (device.platform !== "electron") { + res.status(400).json({ + error: `Device id "${deviceId}" is not an Electron device. Use list-devices to find one.`, + }); + return; + } + let server: ElectronCdpApi; + try { + const ref = electronCdpRef(device); + server = await registry.resolveService(ref.urn, ref.options); + } catch (err) { + res.status(502).json({ + error: `Could not resolve Electron CDP session for ${deviceId}: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + // Lazily build the router per-device. Each ElectronServer is stable for + // the lifetime of the registry entry, so caching the router would only + // save a few object allocations per request; building inline keeps the + // code simple and the failure surface obvious. + const router = createElectronServerRouter(server.server); + router(req, res, next); + }); + app.get("/registry/snapshot", (_req: Request, res: Response) => { const snapshot = registry.getSnapshot(); const services: Record = {}; @@ -413,5 +466,33 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt app, dispose: () => idleTimer.dispose(), getLastActivityAt: () => idleTimer.getLastActivityAt(), + attachElectronWebsockets: (httpServer: HttpServer) => { + attachElectronServerWebsocket(httpServer, "/electron-server/", (req) => { + // URL shape: /electron-server//ws + const match = (req.url ?? "").match(/^\/electron-server\/([^/]+)\/ws(?:[?#]|$)/); + if (!match) return null; + const deviceId = decodeURIComponent(match[1]!); + const device = resolveDeviceForWs(deviceId); + if (device.platform !== "electron") return null; + // The CDP session must already be resolved (the per-device REST routes + // resolve it lazily on first hit). For the WS endpoint we look at the + // current registry snapshot — if no session is open, refuse the + // upgrade instead of triggering a slow CDP connect inside the upgrade + // handler (which would block the TCP socket). + const urn = `${ELECTRON_CDP_NAMESPACE}:${deviceId}`; + const snapshot = registry.getSnapshot(); + if (!snapshot.services.has(urn)) return null; + // Use the synchronous getter on the registry rather than the async + // resolveService — by this point the service is guaranteed to exist. + const node = ( + registry as unknown as { + services: Map; + } + ).services.get(urn); + const api = node?.instance?.api; + if (!api) return null; + return api.server; + }); + }, }; } diff --git a/packages/tool-server/src/index.ts b/packages/tool-server/src/index.ts index 177c2660..11c364ed 100644 --- a/packages/tool-server/src/index.ts +++ b/packages/tool-server/src/index.ts @@ -133,6 +133,10 @@ export function start(): void { ); process.exit(1); }); + // Bolt the per-Electron-device WebSocket upgrade handler onto the live + // server. Must happen AFTER `listen()` so the http.Server instance + // exists; the handler is process-wide so attaching once is enough. + httpHandle.attachElectronWebsockets(server); }) .catch((err) => { process.stderr.write( diff --git a/packages/tool-server/src/preview.ts b/packages/tool-server/src/preview.ts index ade72dba..92155057 100644 --- a/packages/tool-server/src/preview.ts +++ b/packages/tool-server/src/preview.ts @@ -45,31 +45,53 @@ export function createPreviewRouter(registry: Registry): Router { model?: string; sdkLevel?: number | null; } + | { platform: "electron"; id: string; title: string; port: number } >; }>(listDevicesTool.id); // The preview UI keys off `udid` and `state === "Booted"`, which are // iOS terminology. Map Android serials to the same shape so the same // dropdown can target both platforms — `simulator-server/:udid` already // accepts Android serials via `resolveDevice(udid)`. - const simulators = data.devices.map((d) => { + // + // Electron is intentionally excluded: the preview UI streams frames + // through simulator-server's WebSocket, which only exists for iOS / + // Android. Surfacing electron entries would let the UI offer a target + // it can't actually drive. Electron consumers should use the MCP tools + // (screenshot, describe, gesture-*) directly. + type PreviewEntry = { + udid: string; + name: string; + state: string; + runtime: string; + isAvailable: boolean; + platform: "ios" | "android"; + }; + const simulators = data.devices.flatMap((d) => { if (d.platform === "ios") { - return { - udid: d.udid, - name: d.name, - state: d.state, - runtime: d.runtime, - isAvailable: true, - platform: "ios" as const, - }; + return [ + { + udid: d.udid, + name: d.name, + state: d.state, + runtime: d.runtime, + isAvailable: true, + platform: "ios", + }, + ]; } - return { - udid: d.serial, - name: d.avdName ?? d.model ?? d.serial, - state: d.state === "device" ? "Booted" : d.state, - runtime: d.sdkLevel != null ? `Android API ${d.sdkLevel}` : "Android", - isAvailable: true, - platform: "android" as const, - }; + if (d.platform === "android") { + return [ + { + udid: d.serial, + name: d.avdName ?? d.model ?? d.serial, + state: d.state === "device" ? "Booted" : d.state, + runtime: d.sdkLevel != null ? `Android API ${d.sdkLevel}` : "Android", + isAvailable: true, + platform: "android", + }, + ]; + } + return []; }); res.json({ simulators }); } catch (err) { @@ -79,6 +101,17 @@ export function createPreviewRouter(registry: Registry): Router { router.get("/simulator-server/:udid", async (req: Request, res: Response) => { const udid = req.params.udid!; + const device = resolveDevice(udid); + if (device.platform === "electron") { + // The preview UI only knows how to render simulator-server's frame stream, + // and Electron drives the renderer over CDP instead. Fail loudly here so a + // forged URL doesn't quietly spawn a simulator-server process for an + // Electron device id. + res.status(400).json({ + error: `Preview is not available for Electron devices (id "${udid}"). Use the MCP tools (screenshot, describe, gesture-*) directly.`, + }); + return; + } try { // This endpoint is reachable without the auth token (the preview UI is // browser-loaded and tokenless). Bind the spawn to an actually-present @@ -97,7 +130,7 @@ export function createPreviewRouter(registry: Registry): Router { .json({ error: `Unknown device "${udid}". Use a udid/serial from /preview/simulators.` }); return; } - const { urn, options } = simulatorServerRef(resolveDevice(udid)); + const { urn, options } = simulatorServerRef(device); const api = await registry.resolveService(urn, options); res.json({ udid, diff --git a/packages/tool-server/src/tools/button/index.ts b/packages/tool-server/src/tools/button/index.ts index 9c1eeaad..ca7cc3bc 100644 --- a/packages/tool-server/src/tools/button/index.ts +++ b/packages/tool-server/src/tools/button/index.ts @@ -33,6 +33,9 @@ interface Result { const BUTTONS_BY_PLATFORM: Record> = { ios: new Set(["home", "power", "volumeUp", "volumeDown", "appSwitch", "actionButton"]), android: new Set(["home", "back", "power", "volumeUp", "volumeDown", "appSwitch"]), + // Electron apps have no hardware buttons; the capability gate already + // excludes them, the empty set keeps the lookup total if one slips through. + electron: new Set([]), }; const capability: ToolCapability = { diff --git a/packages/tool-server/src/tools/debugger/debugger-component-tree.ts b/packages/tool-server/src/tools/debugger/debugger-component-tree.ts index 4616afe9..176b1bbd 100644 --- a/packages/tool-server/src/tools/debugger/debugger-component-tree.ts +++ b/packages/tool-server/src/tools/debugger/debugger-component-tree.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import * as crypto from "node:crypto"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "./debugger-service-ref"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import { makeComponentTreeScript } from "../../utils/debugger/scripts/component-tree"; @@ -538,6 +539,10 @@ Use when you need tap coordinates for a React Native UI element. Returns a compa alwaysLoad: true, searchHint: "react native component tree discovery tap coordinates", zodSchema, + // RN-only: depends on the React DevTools backend that ships with the JS + // bundle in dev builds. Electron has no equivalent — use `describe` instead + // for DOM-tree discovery on Electron. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/debugger/debugger-connect.ts b/packages/tool-server/src/tools/debugger/debugger-connect.ts index badff801..eb7b2687 100644 --- a/packages/tool-server/src/tools/debugger/debugger-connect.ts +++ b/packages/tool-server/src/tools/debugger/debugger-connect.ts @@ -1,13 +1,17 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; +import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; const zodSchema = z.object({ - port: z.coerce.number().default(8081).describe("Metro server port"), + port: z.coerce + .number() + .default(8081) + .describe("Metro server port (ignored for Electron — its CDP port is encoded in device_id)"), device_id: z .string() .describe( - "Device logicalDeviceId (iOS simulator UDID or Android logicalDeviceId returned by Metro). The returned logicalDeviceId must be forwarded as device_id to all subsequent debugger-* and profiler-* calls to pin them to this device." + "Device id: iOS simulator UDID, Android logicalDeviceId returned by Metro, or Electron device id (electron-cdp-) from list-devices. The returned logicalDeviceId must be forwarded as device_id to all subsequent debugger-* calls to pin them to this device." ), }); @@ -24,12 +28,14 @@ export const debuggerConnectTool: ToolDefinition< } > = { id: "debugger-connect", - description: `Connect to a running Metro dev server's CDP debugger endpoint. -Returns connection info including port, projectRoot, deviceName, appName, logicalDeviceId, and isNewDebugger. If already connected, returns the existing connection. -Use when starting a debug session or before calling other debugger-* tools. Fails if Metro is not running on the specified port.`, + description: `Connect to a JS runtime CDP debugger. +iOS / Android: connects to Metro's CDP endpoint on the given port. Electron: re-uses the page CDP session opened by boot-device — port is ignored. +Returns connection info including port, projectRoot (empty on Electron), deviceName, appName, logicalDeviceId, and isNewDebugger. If already connected, returns the existing connection. +Use when starting a debug session or before calling other debugger-* tools. Fails if the runtime is unreachable (Metro down, or Electron CDP terminated).`, zodSchema, + capability: DEBUGGER_TOOL_CAPABILITY, services: (params) => ({ - debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, + debugger: debuggerServiceRef(params), }), async execute(services) { const api = services.debugger as JsRuntimeDebuggerApi; diff --git a/packages/tool-server/src/tools/debugger/debugger-evaluate.ts b/packages/tool-server/src/tools/debugger/debugger-evaluate.ts index 507af3ae..9fc877b7 100644 --- a/packages/tool-server/src/tools/debugger/debugger-evaluate.ts +++ b/packages/tool-server/src/tools/debugger/debugger-evaluate.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; +import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; const zodSchema = z.object({ - port: z.coerce.number().default(8081).describe("Metro server port"), + port: z.coerce.number().default(8081).describe("Metro server port (ignored for Electron)"), device_id: z .string() .describe( - "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + "Device id from debugger-connect (iOS simulator UDID, Android logicalDeviceId, or Electron device id)." ), expression: z.string().describe("JavaScript expression to evaluate in the app runtime"), }); @@ -17,11 +18,12 @@ export const debuggerEvaluateTool: ToolDefinition< { result: unknown; deviceName: string; appName: string; logicalDeviceId: string | undefined } > = { id: "debugger-evaluate", - description: `Execute arbitrary JavaScript in the React Native app's JS runtime via CDP. + description: `Execute arbitrary JavaScript in the app's JS runtime via CDP — Hermes on iOS / Android, V8 on Electron. Returns the evaluation result as a JSON-serializable value, along with deviceName, appName, and logicalDeviceId for context. Use when you need to read app state, call app functions, or test logic at runtime. Fails if the expression throws or the runtime is not connected.`, zodSchema, + capability: DEBUGGER_TOOL_CAPABILITY, services: (params) => ({ - debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, + debugger: debuggerServiceRef(params), }), async execute(services, params) { const api = services.debugger as JsRuntimeDebuggerApi; diff --git a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts index 0ab93ef7..c94ca72a 100644 --- a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts +++ b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import { makeInspectScript } from "../../utils/debugger/scripts/inspect-at-point"; import { shouldSkip, isHardSkip } from "../../utils/debugger/skip-rules"; +import { RN_ONLY_TOOL_CAPABILITY } from "./debugger-service-ref"; export interface InspectItem { name: string; @@ -174,6 +175,11 @@ Set resolveSourceMaps to false to skip symbolication and get raw bundled locatio Set includeSkipped=true to see filtered items annotated with skip reasons. Use when you need the source file and line for a component at a tap coordinate. Fails if the app is not connected or the coordinate is outside the screen.`, zodSchema, + // RN-only: uses React Native's internal getInspectorDataForViewAtPoint and + // Metro's /symbolicate endpoint. Electron's CDP has DOM.getNodeForLocation + // for "what's here?" but the source-map flow would need a complete rewrite — + // out of scope for this port. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts index 7398df66..3b63537b 100644 --- a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts +++ b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import type { LogStats, MessageCluster } from "../../utils/debugger/log-file-writer"; +import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; interface LogRegistryResponse extends LogStats { clusters: MessageCluster[]; @@ -11,11 +12,11 @@ interface LogRegistryResponse extends LogStats { } const zodSchema = z.object({ - port: z.coerce.number().default(8081).describe("Metro server port"), + port: z.coerce.number().default(8081).describe("Metro server port (ignored for Electron)"), device_id: z .string() .describe( - "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + "Device id from debugger-connect (iOS simulator UDID, Android logicalDeviceId, or Electron device id)." ), }); @@ -24,12 +25,13 @@ export const debuggerLogRegistryTool: ToolDefinition< LogRegistryResponse > = { id: "debugger-log-registry", - description: `Get a summary of all console logs captured from the React Native app. -Returns the log file path, entry counts by level, and message clusters (grouped by similarity). + description: `Get a summary of all console logs captured from the app's JS runtime. +Returns the log file path, entry counts by level, and message clusters (grouped by similarity). Works against Hermes (iOS / Android) and V8 (Electron). Use when investigating warnings, errors, or unexpected output — call this first for an overview, then read the returned file for details. Returns empty stats if no log data has been captured yet.`, zodSchema, + capability: DEBUGGER_TOOL_CAPABILITY, services: (params) => ({ - debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, + debugger: debuggerServiceRef(params), }), async execute(services) { const api = services.debugger as JsRuntimeDebuggerApi; diff --git a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts index f134504b..ea5b6393 100644 --- a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts +++ b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import { DISABLE_LOGBOX_SCRIPT } from "../../utils/debugger/scripts/disable-logbox"; +import { RN_ONLY_TOOL_CAPABILITY } from "./debugger-service-ref"; const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), @@ -27,6 +28,12 @@ export const debuggerReloadMetroTool: ToolDefinition< description: `Restart the Metro JS bundle in the connected React Native app without restarting the native process. Use when you want to apply code changes or reset JS state. Returns { reloaded, port, method, deviceName, appName, logicalDeviceId } indicating which reload path was used and which device/app was targeted. Fails if Metro is not running on the given port.`, zodSchema, + // Metro-only: Electron loads from disk, not from a bundler. The closest + // analog (Page.reload against the renderer) would behave differently enough + // — preserving the URL but re-fetching index.html, blowing away in-memory + // app state — that calling it under the same tool name would mislead. If we + // want that on Electron later, it deserves its own tool. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/debugger/debugger-service-ref.ts b/packages/tool-server/src/tools/debugger/debugger-service-ref.ts new file mode 100644 index 00000000..a11ce081 --- /dev/null +++ b/packages/tool-server/src/tools/debugger/debugger-service-ref.ts @@ -0,0 +1,46 @@ +import type { ServiceRef, ToolCapability } from "@argent/registry"; +import { ELECTRON_ID_PREFIX, resolveDevice } from "../../utils/device-info"; +import { electronJsRuntimeDebuggerRef } from "../../blueprints/electron-js-runtime-debugger"; + +/** + * Capability matrix shared by every debugger-* tool that has been ported to + * Electron CDP. iOS + Android continue to go through Metro; Electron goes + * direct via the page CDP session that boot-device already opened. + */ +export const DEBUGGER_TOOL_CAPABILITY: ToolCapability = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, +}; + +/** + * Capability matrix for debugger-* tools that are NOT portable to Electron — + * they depend on Metro, the RN inspector, or the React DevTools backend. The + * absent `electron` field makes the HTTP capability gate reject them with a + * clear "not supported on electron app" message before they ever run. + */ +export const RN_ONLY_TOOL_CAPABILITY: ToolCapability = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, +}; + +/** + * Build the debugger service ref for the tool's `services()` callback. Routes + * Electron device ids to the parallel ElectronJsRuntimeDebugger blueprint (a + * thin adapter over the existing ElectronCdp session) and falls back to the + * Metro-driven JsRuntimeDebugger blueprint for iOS / Android. The `port` field + * is irrelevant for Electron — its CDP port lives inside the device id — so + * passing 8081 by default in the tools' zodSchemas does no harm. + */ +export function debuggerServiceRef(params: { port: number; device_id?: string }): ServiceRef { + // Only branch into the Electron blueprint when the device_id explicitly + // matches the Electron shape. The Metro path is the default — it has to + // tolerate undefined / empty / malformed ids the same way the original + // template-literal implementation did, because tests and older callers + // expect a Metro URN to come back even when device_id is missing. + if (params.device_id && params.device_id.startsWith(ELECTRON_ID_PREFIX)) { + const device = resolveDevice(params.device_id); + return electronJsRuntimeDebuggerRef(device); + } + return `JsRuntimeDebugger:${params.port}:${params.device_id}`; +} diff --git a/packages/tool-server/src/tools/debugger/debugger-status.ts b/packages/tool-server/src/tools/debugger/debugger-status.ts index f348ec40..bd128144 100644 --- a/packages/tool-server/src/tools/debugger/debugger-status.ts +++ b/packages/tool-server/src/tools/debugger/debugger-status.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; +import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; const zodSchema = z.object({ - port: z.coerce.number().default(8081).describe("Metro server port"), + port: z.coerce.number().default(8081).describe("Metro server port (ignored for Electron)"), device_id: z .string() .describe( - "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + "Device id from debugger-connect (iOS simulator UDID, Android logicalDeviceId, or Electron device id)." ), }); @@ -28,10 +29,11 @@ export const debuggerStatusTool: ToolDefinition< > = { id: "debugger-status", description: `Get JS runtime debugger connection status and diagnostic info. -Use when you need to verify connectivity before using other debugger tools. Returns port, projectRoot, deviceName, appName, logicalDeviceId, connected flag, loadedScripts count, and sourceMapReady (always true — waits for pending source maps before returning). Fails if Metro is unreachable.`, +Use when you need to verify connectivity before using other debugger tools. Returns port, projectRoot (empty on Electron), deviceName, appName, logicalDeviceId, connected flag, loadedScripts count, and sourceMapReady (always true — waits for pending source maps before returning; no-op on Electron). Fails if the runtime is unreachable.`, zodSchema, + capability: DEBUGGER_TOOL_CAPABILITY, services: (params) => ({ - debugger: `JsRuntimeDebugger:${params.port}:${params.device_id}`, + debugger: debuggerServiceRef(params), }), async execute(services) { const api = services.debugger as JsRuntimeDebuggerApi; diff --git a/packages/tool-server/src/tools/describe/contract.ts b/packages/tool-server/src/tools/describe/contract.ts index 9fb6d89b..13b6243a 100644 --- a/packages/tool-server/src/tools/describe/contract.ts +++ b/packages/tool-server/src/tools/describe/contract.ts @@ -52,11 +52,18 @@ export const describeNodeSchema: z.ZodType = z.lazy(() => ); // Where the tree came from. "ax-service" / "native-devtools" come from iOS; -// "uiautomator" / "android-devtools" come from Android. Agents that branch on -// `source` (e.g. to decide whether to also call `native-find-views` for a -// richer tree) need to distinguish the Android cases from an iOS native- -// devtools fallback — which a shared label would hide. -export type DescribeSource = "ax-service" | "native-devtools" | "uiautomator" | "android-devtools"; +// Where the tree came from. "ax-service" / "native-devtools" come from iOS; +// "uiautomator" / "android-devtools" come from Android; "cdp-dom" is the +// Electron branch's DOM walk over Chrome DevTools Protocol. Agents that branch +// on `source` (e.g. to decide whether to also call `native-find-views` for a +// richer tree) need to distinguish each provider — which a shared label would +// hide. +export type DescribeSource = + | "ax-service" + | "native-devtools" + | "uiautomator" + | "android-devtools" + | "cdp-dom"; // Internal shape produced by the per-platform adapters. The `tree` is consumed // by the formatter in `format-tree.ts` and then dropped before the tool replies diff --git a/packages/tool-server/src/tools/describe/format-tree.ts b/packages/tool-server/src/tools/describe/format-tree.ts index 7d671b96..4a2cc988 100644 --- a/packages/tool-server/src/tools/describe/format-tree.ts +++ b/packages/tool-server/src/tools/describe/format-tree.ts @@ -163,8 +163,15 @@ export interface FormatDescribeOptions { } export function formatDescribeTree(root: DescribeNode, opts: FormatDescribeOptions): string { + // iOS providers (ax-service, native-devtools) emit a flat list under a + // synthetic root, so the flat renderer is correct. Sources that produce + // real parent/child trees (uiautomator / android-devtools on Android, + // cdp-dom on Electron) use the nested renderer so descendants beyond + // depth 1 are visible. const mode: "flat" | "nested" = - opts.source === "uiautomator" || opts.source === "android-devtools" ? "nested" : "flat"; + opts.source === "uiautomator" || opts.source === "android-devtools" || opts.source === "cdp-dom" + ? "nested" + : "flat"; const header: string[] = []; header.push(`Source: ${opts.source}`); header.push(`Mode: ${mode}`); diff --git a/packages/tool-server/src/tools/describe/index.ts b/packages/tool-server/src/tools/describe/index.ts index 8997a202..39c0349d 100644 --- a/packages/tool-server/src/tools/describe/index.ts +++ b/packages/tool-server/src/tools/describe/index.ts @@ -1,9 +1,12 @@ import { z } from "zod"; -import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; +import type { Registry, ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import type { DescribeResult, DescribeTreeData } from "./contract"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; import { describeAndroid, androidRequires } from "./platforms/android"; import { iosRequires, describeIos } from "./platforms/ios"; +import { describeElectron } from "./platforms/electron"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; +import { resolveDevice } from "../../utils/device-info"; import { formatDescribeTree } from "./format-tree"; // In-between layer between the per-platform adapters (which still own all @@ -25,14 +28,14 @@ const zodSchema = z.object({ udid: z .string() .min(1) - .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), bundleId: z .string() .optional() .describe( "Optional app bundle ID. Used as a target hint on iOS when the AX-service returns no elements " + "and the describe tool falls back to native-devtools inspection. " + - "If omitted, the fallback auto-detects the frontmost connected app. Ignored on Android." + "If omitted, the fallback auto-detects the frontmost connected app. Ignored on Android / Electron." ), }); @@ -41,26 +44,35 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; +interface ElectronServices { + electron: ElectronCdpApi; +} + // `describe` doesn't fit dispatchByPlatform's standard service-typed // signature because the iOS handler resolves AX / native-devtools through // `registry` (closed over below) rather than via the registry's services() // declaration. We still feed `iosRequires` / `androidRequires` to the -// dispatcher so the per-branch host-binary preflight fires uniformly. +// dispatcher so the per-branch host-binary preflight fires uniformly. The +// Electron branch *does* go through services() since the CDP session lives in +// the registry as a normal service blueprint. export function createDescribeTool(registry: Registry): ToolDefinition { return { id: "describe", - description: `Get the accessibility element tree for the current screen. + description: `Get the accessibility / DOM element tree for the current screen. On iOS, uses the AXRuntime accessibility service to inspect whatever is currently visible — including system dialogs, permission prompts, and any foreground app content. On Android, runs \`uiautomator dump\`. +On Electron, walks the renderer's DOM via Chrome DevTools Protocol — every visible element with its ARIA +role, accessible name, and bounding rect (normalized to 0–1). When a system dialog is visible, describe returns the dialog's interactive elements (buttons, text) with tap coordinates. When no dialog is present, it returns the foreground app's accessible elements. Returns \`{ description, source }\` where \`description\` is a text rendering of the UI tree — one line per element with its role, label/value/id, interactivity flags, and frame. Frame coordinates -are normalized [0,1] fractions of the screen width/height (not pixels) — the same space as +are normalized [0,1] fractions of the screen / window width/height (not pixels) — the same space as gesture-tap / gesture-swipe / gesture-pinch. To tap an element use the centre of its frame: \`tap_x = frame.x + frame.width / 2\`, @@ -71,15 +83,22 @@ For app-scoped inspection with full UIKit properties (accessibilityIdentifier, v use native-describe-screen with an explicit bundleId instead (iOS only). For React Native apps, debugger-component-tree returns React component names with tap coordinates.`, alwaysLoad: true, - searchHint: "accessibility element tree ui hierarchy tap coordinates ios android", + searchHint: "accessibility element tree ui hierarchy tap coordinates ios android electron dom", zodSchema, capability, - services: () => ({}), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return {}; + }, execute: dispatchByPlatform< Record, Record, Params, - DescribeResult + DescribeResult, + ElectronServices >({ toolId: "describe", capability, @@ -93,6 +112,9 @@ For React Native apps, debugger-component-tree returns React component names wit handler: async (_services, params) => withDescription(await describeAndroid(registry, params.udid, params.bundleId)), }, + electron: { + handler: async (services) => withDescription(await describeElectron(services.electron)), + }, }), }; } diff --git a/packages/tool-server/src/tools/describe/platforms/electron.ts b/packages/tool-server/src/tools/describe/platforms/electron.ts new file mode 100644 index 00000000..9d0c05fc --- /dev/null +++ b/packages/tool-server/src/tools/describe/platforms/electron.ts @@ -0,0 +1,262 @@ +import type { ElectronCdpApi } from "../../../blueprints/electron-cdp"; +import type { DescribeNode, DescribeTreeData } from "../contract"; + +/** + * In-page script that returns a JSON UI tree mirroring `DescribeNode`. We + * collect ARIA role / accessible name, interactivity flags, and bounding + * rects normalized to fractions of window.innerWidth/innerHeight (matching + * the iOS/Android describe contract, so the same frame-centre tap math + * applies on Electron). + * + * Choices: + * - Walk children plus open shadow roots and same-origin iframe documents so + * modern Electron apps (VS Code, Slack, custom-element-heavy SPAs) don't + * appear as empty pages. + * - Skip purely structural wrappers (anonymous single-child divs) so the + * tree stays small. + * - Treat anchors, buttons, inputs, [role=button], [onclick], [tabindex]≥0 + * as `clickable: true` so the agent knows which nodes to tap. + * - Use rect.width/rect.height === 0 to prune invisible nodes (display:none + * yields a zero-sized rect; visibility:hidden does not, so we also + * short-circuit on computed `visibility: hidden` for the root walk). + * - Cap depth at 24 and node count at 5000 — a runaway SPA otherwise + * serializes a payload too large for CDP to deliver in a single + * Runtime.evaluate reply (~50MB practical limit). + */ +const DESCRIBE_DOM_SCRIPT = `(() => { + const MAX_DEPTH = 24; + const MAX_NODES = 5000; + let nodeBudget = MAX_NODES; + let truncated = false; + const w = window.innerWidth; + const h = window.innerHeight; + if (!w || !h) return JSON.stringify({ tree: null, error: "viewport is zero" }); + + function nodeRole(el) { + const r = el.getAttribute("role"); + if (r) return r; + const t = el.tagName.toLowerCase(); + return t; + } + + function accessibleName(el) { + const aria = el.getAttribute("aria-label"); + if (aria) return aria.trim().slice(0, 200); + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + const ids = labelledBy.split(/\\s+/); + const parts = []; + for (const id of ids) { + const ref = document.getElementById(id); + if (ref) parts.push((ref.textContent || "").trim()); + } + if (parts.length) return parts.join(" ").slice(0, 200); + } + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + if (el.placeholder) return el.placeholder.slice(0, 200); + if (el.value) return el.value.slice(0, 200); + } + if (el instanceof HTMLImageElement && el.alt) return el.alt.slice(0, 200); + const t = el.title; + if (t) return t.slice(0, 200); + return null; + } + + function ownText(el) { + let s = ""; + for (const child of el.childNodes) { + if (child.nodeType === 3) s += child.nodeValue; + } + return s.replace(/\\s+/g, " ").trim(); + } + + function isInteractive(el) { + const tag = el.tagName.toLowerCase(); + if (tag === "a" && el.href) return true; + if (tag === "button") return true; + if (tag === "input" || tag === "textarea" || tag === "select") return true; + if (tag === "summary" || tag === "details") return true; + if (el.hasAttribute("onclick")) return true; + const role = el.getAttribute("role"); + if (role && /^(button|link|tab|menuitem|checkbox|radio|switch|option)$/i.test(role)) return true; + const tabIndex = el.getAttribute("tabindex"); + if (tabIndex !== null && tabIndex !== "-1") return true; + return false; + } + + function isDisabled(el) { + if (el.hasAttribute("disabled")) return true; + if (el.getAttribute("aria-disabled") === "true") return true; + return false; + } + + function isChecked(el) { + if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) { + return el.checked; + } + const v = el.getAttribute("aria-checked"); + if (v === "true") return true; + return false; + } + + function isPassword(el) { + return el instanceof HTMLInputElement && el.type === "password"; + } + + function isScrollable(el) { + const style = window.getComputedStyle(el); + const oy = style.overflowY; + const ox = style.overflowX; + if (oy === "auto" || oy === "scroll" || ox === "auto" || ox === "scroll") { + if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) return true; + } + return false; + } + + function frame(el) { + const r = el.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, r.left / w)); + const y = Math.max(0, Math.min(1, r.top / h)); + const right = Math.max(0, Math.min(1, r.right / w)); + const bottom = Math.max(0, Math.min(1, r.bottom / h)); + return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) }; + } + + function visible(el) { + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return false; + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") { + return false; + } + return true; + } + + function walk(el, depth) { + if (truncated) return null; + if (depth > MAX_DEPTH) return null; + if (!(el instanceof Element)) return null; + if (!visible(el)) return null; + if (nodeBudget <= 0) { + truncated = true; + return null; + } + nodeBudget--; + + const childResults = []; + for (const child of el.children) { + const c = walk(child, depth + 1); + if (c) childResults.push(c); + } + + // Pierce open shadow roots — closed roots are unreachable by design. + // Web-components-heavy apps (VS Code, every Lit/Polymer SPA) put their + // interactive content under .shadowRoot, so without this descent describe + // returns an empty body. + if (el.shadowRoot) { + for (const child of el.shadowRoot.children) { + const c = walk(child, depth + 1); + if (c) childResults.push(c); + } + } + + // Same-origin iframes: pierce contentDocument if accessible. Cross-origin + // contentDocument access throws SecurityError — swallowed silently so the + // walker doesn't abort the whole tree. + if (el.tagName === "IFRAME") { + try { + const doc = el.contentDocument; + if (doc && doc.documentElement) { + const c = walk(doc.documentElement, depth + 1); + if (c) childResults.push(c); + } + } catch (e) { + /* cross-origin iframe — skip */ + } + } + + const text = ownText(el); + const name = accessibleName(el); + const clickable = isInteractive(el); + const role = nodeRole(el); + // Prune structural wrappers with no info that just add a layer. + // Keep them if they're roots/clickable/named/have text. + if ( + depth > 0 && + childResults.length === 1 && + !clickable && + !name && + !text && + role === "div" + ) { + return childResults[0]; + } + const node = { + role, + frame: frame(el), + children: childResults, + }; + if (name) node.label = name; + if (text && text !== name) node.value = text.slice(0, 200); + const id = el.id || el.getAttribute("data-testid") || el.getAttribute("data-test-id"); + if (id) node.identifier = id; + if (clickable) node.clickable = true; + if (isDisabled(el)) node.disabled = true; + if (isChecked(el)) node.checked = true; + if (isPassword(el)) node.password = true; + if (isScrollable(el)) node.scrollable = true; + return node; + } + + const root = walk(document.documentElement, 0) || { + role: "html", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: [], + }; + return JSON.stringify({ tree: root, truncated }); +})()`; + +export async function describeElectron(api: ElectronCdpApi): Promise { + // Make sure the cached viewport is fresh — the script normalizes frames by + // the live window dimensions, so any rescroll between calls is reflected. + await api.refreshViewport(); + const raw = (await api.cdp.send("Runtime.evaluate", { + expression: DESCRIBE_DOM_SCRIPT, + returnByValue: true, + })) as { + result?: { value?: string }; + exceptionDetails?: { text?: string }; + }; + if (raw.exceptionDetails) { + throw new Error( + `Electron describe failed: ${raw.exceptionDetails.text ?? "renderer evaluation threw"}` + ); + } + const payload = raw.result?.value; + if (typeof payload !== "string") { + throw new Error("Electron describe: renderer returned no value"); + } + let parsed: { tree?: DescribeNode | null; truncated?: boolean; error?: string }; + try { + parsed = JSON.parse(payload); + } catch (err) { + throw new Error( + `Electron describe: could not parse renderer payload: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (parsed.error) { + throw new Error(`Electron describe: ${parsed.error}`); + } + if (!parsed.tree) { + throw new Error("Electron describe: empty tree"); + } + if (parsed.truncated) { + // Surface a server-side warning so a partial tree is visible to ops. + // A flag in DescribeTreeData would be cleaner but the contract is shared + // with iOS/Android and we don't want to widen it just for Electron. + process.stderr.write( + `[electron-describe] tree truncated at MAX_NODES — page exceeds the walker's budget; consider scoping the inspection.\n` + ); + } + return { tree: parsed.tree, source: "cdp-dom" }; +} diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index f3db9280..0e13e8f2 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -27,6 +27,7 @@ import { import { ensureDep } from "../../utils/check-deps"; import { linuxBootDiagnostics } from "../../utils/linux-preflight"; import { listIosSimulators } from "../../utils/ios-devices"; +import { bootElectronApp, type ElectronBootResult } from "./boot-electron"; const execFileAsync = promisify(execFile); @@ -63,6 +64,27 @@ const zodSchema = z.object({ .boolean() .optional() .describe("Shut down and re-boot the device even if already running."), + electronAppPath: z + .string() + .optional() + .describe( + "Electron: path to the Electron app to launch. Either a packaged .app bundle / executable, or a project directory whose package.json points the Electron binary at the entry script. Mutually exclusive with udid/avdName." + ), + electronPort: z + .number() + .int() + .min(1024) + .max(65535) + .optional() + .describe( + "Electron-only: CDP remote-debugging port to expose. Defaults to a free port; the resulting device id is `electron-cdp-`." + ), + electronArgs: z + .array(z.string()) + .optional() + .describe( + "Electron-only: extra CLI arguments forwarded to the Electron binary after the app path." + ), }); type BootDeviceParams = z.infer; @@ -70,6 +92,7 @@ type BootDeviceParams = z.infer; type BootDeviceResult = | { platform: "ios"; udid: string; booted: true } | { platform: "android"; serial: string; avdName: string; booted: true } + | ElectronBootResult | NativeDevtoolsInitFailedResult; // Flags every boot-device launch should always pass. Two purposes: @@ -998,13 +1021,15 @@ function createEarlyExitRacer(getExit: () => Error | null): { }; } -// boot-device dispatches internally on `udid` vs `avdName` rather than via -// `dispatchByPlatform` (the helper assumes a single udid input). Capability -// is still declared so the HTTP gate rejects an iOS udid on a host without -// xcrun, etc., and so `list-devices` consumers can rely on uniform metadata. +// boot-device dispatches internally on `udid` vs `avdName` vs `electronAppPath` +// rather than via `dispatchByPlatform` (the helper assumes a single udid +// input). Capability is still declared so the HTTP gate rejects an iOS udid +// on a host without xcrun, etc., and so `list-devices` consumers can rely on +// uniform metadata. const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; export function createBootDeviceTool( @@ -1012,11 +1037,11 @@ export function createBootDeviceTool( ): ToolDefinition { return { id: "boot-device", - description: `Start an iOS simulator or launch an Android emulator and wait until it is ready to accept interactions. -Pick the platform by which argument you pass: 'udid' for an iOS simulator from list-devices, or 'avdName' for an Android AVD (a serial is assigned automatically). + description: `Start an iOS simulator, launch an Android emulator, or spawn an Electron app and wait until it is ready to accept interactions. +Pick the platform by which argument you pass: 'udid' for an iOS simulator from list-devices, 'avdName' for an Android AVD (a serial is assigned automatically), or 'electronAppPath' for an Electron app (a CDP remote-debugging port is picked automatically, or pass 'electronPort' to fix one). Use at the start of a session once you have picked a target. -Returns a tagged payload: { platform: 'ios', udid, booted } or { platform: 'android', serial, avdName, booted }. -Android boots take 2–10 minutes depending on machine and cold/warm state; the tool transparently hot-boots from the AVD's default_boot snapshot when usable and falls back to cold boot otherwise. If any boot stage fails, the tool terminates the emulator it spawned so the next retry starts clean.`, +Returns a tagged payload: { platform: 'ios', udid, booted } or { platform: 'android', serial, avdName, booted } or { platform: 'electron', id, port, pid, booted }. +Android boots take 2–10 minutes depending on machine and cold/warm state; the tool transparently hot-boots from the AVD's default_boot snapshot when usable and falls back to cold boot otherwise. If any boot stage fails, the tool terminates the device it spawned so the next retry starts clean.`, alwaysLoad: true, searchHint: "boot start launch simulator emulator avd device session ios android cold hot", zodSchema, @@ -1025,16 +1050,27 @@ Android boots take 2–10 minutes depending on machine and cold/warm state; the async execute(_services, params) { const hasUdid = Boolean(params.udid); const hasAvd = Boolean(params.avdName); - if (hasUdid === hasAvd) { - throw new Error("Provide exactly one of `udid` (iOS) or `avdName` (Android)."); + const hasElectron = Boolean(params.electronAppPath); + const provided = [hasUdid, hasAvd, hasElectron].filter(Boolean).length; + if (provided !== 1) { + throw new Error( + "Provide exactly one of `udid` (iOS), `avdName` (Android), or `electronAppPath` (Electron)." + ); } if (hasUdid) { return bootIos(params.udid!, registry, params.force); } - return bootAndroid({ - avdName: params.avdName!, - bootTimeoutMs: params.bootTimeoutMs ?? 480_000, - force: params.force, + if (hasAvd) { + return bootAndroid({ + avdName: params.avdName!, + bootTimeoutMs: params.bootTimeoutMs ?? 480_000, + force: params.force, + }); + } + return bootElectronApp({ + appPath: params.electronAppPath!, + port: params.electronPort, + extraArgs: params.electronArgs, }); }, }; diff --git a/packages/tool-server/src/tools/devices/boot-electron.ts b/packages/tool-server/src/tools/devices/boot-electron.ts new file mode 100644 index 00000000..1f05cb2b --- /dev/null +++ b/packages/tool-server/src/tools/devices/boot-electron.ts @@ -0,0 +1,290 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as path from "node:path"; +import { ensureCdpReachable } from "../../blueprints/electron-cdp"; +import { electronIdFromPort } from "../../utils/device-info"; +import { trackElectronPort } from "../../utils/electron-discovery"; + +export interface ElectronBootResult { + platform: "electron"; + id: string; + port: number; + pid: number; + appPath: string; + booted: true; +} + +interface BootElectronOptions { + appPath: string; + port?: number; + extraArgs?: string[]; + /** Defaults to 30s. */ + readyTimeoutMs?: number; +} + +const DEFAULT_READY_TIMEOUT_MS = 30_000; + +/** Pick a free localhost port the kernel hands out. */ +async function pickFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Could not allocate a free TCP port"))); + } + }); + }); +} + +/** + * Pick the Electron binary to spawn: + * - If `appPath` is a directory, look for `node_modules/.bin/electron` inside it. + * - If it's a packaged macOS .app bundle, return its Contents/MacOS/ path. + * - Otherwise assume the path itself is the executable. + * + * Returns `{ command, args }` where args are the prefix BEFORE the user's --remote-debugging-port flag. + */ +function resolveLauncher(appPath: string): { command: string; args: string[] } { + const abs = path.resolve(appPath); + if (!fs.existsSync(abs)) { + throw new Error(`Electron boot: path does not exist: ${abs}`); + } + const stat = fs.statSync(abs); + if (stat.isDirectory()) { + if (abs.endsWith(".app")) { + // macOS packaged app bundle. Read Contents/Info.plist's CFBundleExecutable + // for the real binary name; fall back to the basename. + const macOsDir = path.join(abs, "Contents", "MacOS"); + if (!fs.existsSync(macOsDir)) { + throw new Error( + `Electron boot: ${abs} is a .app bundle but has no Contents/MacOS. ` + + `Pass the inner binary directly, or use the project directory of an unpackaged app.` + ); + } + const entries = fs.readdirSync(macOsDir).filter((name) => !name.startsWith(".")); + if (entries.length === 0) { + throw new Error(`Electron boot: ${macOsDir} is empty.`); + } + // Prefer one matching the .app folder name, otherwise take the first. + const bundleName = path.basename(abs, ".app"); + const exec = entries.find((n) => n === bundleName) ?? entries[0]!; + return { command: path.join(macOsDir, exec), args: [] }; + } + // Unpackaged project directory — use ./node_modules/.bin/electron if present. + const localBin = path.join(abs, "node_modules", ".bin", "electron"); + if (fs.existsSync(localBin)) { + return { command: localBin, args: [abs] }; + } + // Fall back to PATH-resolved `electron`. + return { command: "electron", args: [abs] }; + } + // A file — assume it's executable. + return { command: abs, args: [] }; +} + +async function waitForCdpReady(port: number, deadlineMs: number): Promise { + const deadline = Date.now() + deadlineMs; + let lastErr: unknown = null; + while (Date.now() < deadline) { + try { + await ensureCdpReachable(port); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 250)); + } + } + const detail = lastErr instanceof Error ? lastErr.message : String(lastErr); + throw new Error( + `Electron CDP never became reachable on port ${port} within ${deadlineMs}ms. ${detail}` + ); +} + +/** + * Spawn an Electron app and wait until its CDP endpoint is responding. + * + * The child is detached so the tool-server's lifecycle does not own it — the + * caller manages the app process explicitly through Electron's own quit / + * close-window flows. We `unref()` the process; closing the tool-server does + * not bring the app down (matching the simulator-server pattern where the + * simulator outlives the bridge). + */ +/** + * Strip user-supplied --remote-debugging-port from extraArgs so the caller + * can't accidentally point Electron at a different CDP port than the one we + * tracked and reported back. Last-wins on Chromium's flag parser, so a stray + * override would otherwise silently break list-devices / interaction tools. + */ +function sanitizeExtraArgs(extra: string[]): string[] { + return extra.filter((a) => { + if (a === "--remote-debugging-port" || a.startsWith("--remote-debugging-port=")) { + process.stderr.write( + `[electron-boot] dropping user-supplied "${a}" — Argent manages the CDP port.\n` + ); + return false; + } + return true; + }); +} + +function killChildEscalating(child: ChildProcess): void { + // SIGTERM lets Electron flush the renderer's GPU buffers and write a clean + // exit code; SIGKILL after 2s catches stuck processes (hardware-accelerated + // GPU shutdown can deadlock on some Intel drivers). + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + } + }, 2000).unref(); +} + +export async function bootElectronApp(options: BootElectronOptions): Promise { + const port = options.port ?? (await pickFreePort()); + const launcher = resolveLauncher(options.appPath); + const extra = sanitizeExtraArgs(options.extraArgs ?? []); + + const args = [...launcher.args, `--remote-debugging-port=${port}`, ...extra]; + + let child: ChildProcess; + try { + child = spawn(launcher.command, args, { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ELECTRON_ENABLE_LOGGING: "1" }, + }); + } catch (err) { + throw new Error( + `Electron boot: failed to spawn ${launcher.command}: ${err instanceof Error ? err.message : String(err)}` + ); + } + + // Attach the `error` listener BEFORE checking pid / wiring anything else. + // Node's `spawn()` returns synchronously, but ENOENT / EACCES / EAGAIN are + // delivered as a deferred `'error'` event on the next tick. EventEmitter + // convention: an unhandled `error` event escapes as an uncaught exception — + // here that would crash the entire tool-server every time someone called + // boot-device with `electronAppPath` on a host that doesn't have electron + // on PATH. Fold the event into the readiness race so the caller sees a + // clean rejection instead. + const onSpawnError = (err: NodeJS.ErrnoException, reject: (e: Error) => void) => { + const codeSuffix = err.code ? ` (${err.code})` : ""; + reject( + new Error( + `Electron boot: failed to launch ${launcher.command}${codeSuffix}: ${err.message}. ` + + `Make sure 'electron' is installed (npm i electron in the app dir, or globally) and on PATH.` + ) + ); + }; + let spawnErrorReject: ((e: Error) => void) | null = null; + const spawnError = new Promise((_resolve, reject) => { + spawnErrorReject = reject; + }); + const spawnErrorListener = (err: NodeJS.ErrnoException) => { + if (spawnErrorReject) onSpawnError(err, spawnErrorReject); + }; + child.once("error", spawnErrorListener); + + if (!child.pid) { + // No pid + no async error yet is still possible on some platforms when + // spawn fails very early. Detach the error listener before throwing so a + // deferred `'error'` event delivered after this synchronous throw doesn't + // resolve onto an orphan promise (which Node would surface as an + // UnhandledPromiseRejection and — with default --unhandled-rejections=throw + // — crash the tool-server). + child.removeListener("error", spawnErrorListener); + spawnErrorReject = null; + throw new Error(`Electron boot: spawn returned without a pid (binary: ${launcher.command}).`); + } + + // Forward Electron stderr to our stderr so launch failures are visible to + // the user / agent. Drop stdout (renderer chatter) to keep tool-server logs clean. + child.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write(`[electron-cdp-${port}] ${chunk}`); + }); + child.unref(); + + // Race the readiness probe against the child's exit event. If the process + // dies before CDP comes up (e.g. main.js crashes during startup), without + // this race the caller would see a generic readiness-timeout error 30s + // later instead of "process exited with code N". + // + // Both onExit and the earlier spawnErrorListener stay attached to the child + // for the duration of Promise.race below. After we resolve (success OR + // failure), they MUST be detached: the child is detached + unref'd, so it + // outlives this function. A natural exit later (e.g. user closes the + // Electron window) would otherwise reject the orphan `earlyExit` promise + // with "exited with code 0" → unhandled rejection → tool-server crash. + // Same shape as the no-pid throw path above, just for the steady-state run. + let earlyExitReject: ((e: Error) => void) | null = null; + const earlyExit = new Promise((_resolve, reject) => { + earlyExitReject = reject; + }); + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + if (!earlyExitReject) return; + const reason = signal ? `signal ${signal}` : `code ${code ?? "?"}`; + earlyExitReject( + new Error( + `Electron boot: child process exited with ${reason} before CDP was ready. Inspect [electron-cdp-${port}] stderr above for the cause.` + ) + ); + }; + child.once("exit", onExit); + + const detachBootListeners = () => { + child.removeListener("error", spawnErrorListener); + child.removeListener("exit", onExit); + spawnErrorReject = null; + earlyExitReject = null; + }; + + try { + await Promise.race([ + waitForCdpReady(port, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS), + earlyExit, + spawnError, + ]); + } catch (err) { + // CDP didn't come up — terminate the orphan so we don't leak a process. + // Detach the boot listeners first so the impending kill→exit doesn't + // chain into a stale earlyExit rejection. + // + // INVARIANT: detachBootListeners() MUST be the first synchronous + // statement in this catch block — no awaits before it. The boot-time + // listeners would otherwise keep firing during any awaited cleanup and + // re-introduce the orphan-rejection bug this commit closes. + detachBootListeners(); + killChildEscalating(child); + throw err; + } + // Happy path: detach the boot-time listeners now that race has resolved. + // The child is intentionally long-lived; any later exit / error belongs + // to whatever code subsequently manages the session, not to this boot fn. + detachBootListeners(); + + trackElectronPort(port); + + return { + platform: "electron", + id: electronIdFromPort(port), + port, + pid: child.pid, + appPath: path.resolve(options.appPath), + booted: true, + }; +} diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts index 6057859e..dada1ce8 100644 --- a/packages/tool-server/src/tools/devices/list-devices.ts +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { listAndroidDevices, listAvds } from "../../utils/adb"; import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices"; +import { discoverElectronDevices, type ElectronDevice } from "../../utils/electron-discovery"; type IosDevice = IosSimulator & { platform: "ios" }; @@ -16,7 +17,7 @@ type AndroidDevice = { }; type ListDevicesResult = { - devices: Array; + devices: Array; avds: Array<{ name: string }>; }; @@ -40,28 +41,32 @@ function sortAndroid(a: AndroidDevice, b: AndroidDevice): number { // Float booted/ready devices to the top of the merged list regardless of // platform — without this, all iOS entries are emitted before any Android. -function readinessRank(d: IosDevice | AndroidDevice): number { +function readinessRank(d: IosDevice | AndroidDevice | ElectronDevice): number { if (d.platform === "ios") return d.state === "Booted" ? 0 : 1; - return d.state === "device" ? 0 : 1; + if (d.platform === "android") return d.state === "device" ? 0 : 1; + return 0; // Electron entries are only listed when their CDP is responsive } const zodSchema = z.object({}); export const listDevicesTool: ToolDefinition, ListDevicesResult> = { id: "list-devices", - description: `List iOS simulators and Android devices/emulators in one place. -Use at the start of a session to pick a target id ('udid' for iOS entries, 'serial' for Android) to pass to interaction tools, and to see which targets are already running. -Returns { devices, avds } where each device carries a 'platform' discriminator ('ios' or 'android'), and 'avds' lists Android AVDs that can be booted via boot-device. + description: `List iOS simulators, Android devices/emulators, and running Electron apps in one place. +Use at the start of a session to pick a target id ('udid' for iOS entries, 'serial' for Android, 'id' for Electron) to pass to interaction tools, and to see which targets are already running. +Returns { devices, avds } where each device carries a 'platform' discriminator ('ios', 'android', or 'electron'), and 'avds' lists Android AVDs that can be booted via boot-device. +Electron apps are discovered by probing CDP debugging ports (default 9222; extend via the ARGENT_ELECTRON_PORTS= env var). They must already be running with --remote-debugging-port= — use boot-device with electronAppPath to launch one. Booted/ready devices are listed first. Platforms whose CLI is unavailable are silently omitted — an empty result usually means xcode-select or Android platform-tools is not installed.`, alwaysLoad: true, - searchHint: "list devices simulators emulators avd serial udid ios android session start", + searchHint: + "list devices simulators emulators avd serial udid ios android electron app session start", zodSchema, services: () => ({}), async execute(_services, _params) { - const [ios, android, avds] = await Promise.all([ + const [ios, android, avds, electron] = await Promise.all([ listIosSimulators(), listAndroidDevices().catch(() => []), listAvds(), + discoverElectronDevices().catch(() => []), ]); const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); iosTagged.sort(sortIos); @@ -76,7 +81,11 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si })); androidTagged.sort(sortAndroid); - const devices: Array = [...iosTagged, ...androidTagged]; + const devices: Array = [ + ...iosTagged, + ...androidTagged, + ...electron, + ]; devices.sort((a, b) => readinessRank(a) - readinessRank(b)); return { devices, avds }; diff --git a/packages/tool-server/src/tools/gesture-drag/index.ts b/packages/tool-server/src/tools/gesture-drag/index.ts new file mode 100644 index 00000000..8c3496ec --- /dev/null +++ b/packages/tool-server/src/tools/gesture-drag/index.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; +import { resolveDevice } from "../../utils/device-info"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const zodSchema = z.object({ + udid: z.string().describe("Target Electron device id from `list-devices` (electron-cdp-)."), + fromX: z.number().describe("Press x: normalized 0.0–1.0 (fraction of window width, not pixels)."), + fromY: z + .number() + .describe("Press y: normalized 0.0–1.0 (fraction of window height, not pixels)."), + toX: z.number().describe("Release x: normalized 0.0–1.0 (not pixels; same space as tap)."), + toY: z.number().describe("Release y: normalized 0.0–1.0 (not pixels; same space as tap)."), + durationMs: z + .number() + .optional() + .describe("Total drag duration in milliseconds (default 300), interpolated at ~60fps."), +}); + +type Params = z.infer; + +interface Result { + dragged: boolean; + timestampMs: number; +} + +// Electron only. Touch platforms express a drag through gesture-swipe's +// touch sequence; on a desktop renderer the equivalent is a left-button +// mouse drag. Note a desktop drag never scrolls content — that's +// gesture-scroll's job — it moves things: slider thumbs, drag-and-drop +// payloads, text selections, window-content widgets. +const capability: ToolCapability = { + electron: { app: true }, +}; + +export const gestureDragTool: ToolDefinition = { + id: "gesture-drag", + description: `Press the left mouse button at a start point, move to an end point, and release — a desktop mouse drag in an Electron app. All positions are normalized 0.0–1.0 (fractions of the window, not pixels), same coordinate space as gesture-tap and describe. Interpolates mouse-move events at ~60fps over durationMs for a natural drag. +Use for slider thumbs, drag-and-drop, text selection, or draggable UI elements. Dragging never scrolls content on desktop — use gesture-scroll for lists/pages. Electron only — on iOS/Android use gesture-swipe. +Returns { dragged: true, timestampMs }. Fails if the Electron CDP session is not reachable for the given device.`, + alwaysLoad: true, + searchHint: "drag drop slider mouse press move release electron select", + zodSchema, + capability, + services: (params): Record => ({ + electron: electronCdpRef(resolveDevice(params.udid)), + }), + async execute(services, params) { + const timestampMs = Date.now(); + const electron = services.electron as ElectronCdpApi; + const vp = electron.getViewport(); + const startPx = { x: params.fromX * vp.width, y: params.fromY * vp.height }; + const endPx = { x: params.toX * vp.width, y: params.toY * vp.height }; + const durationMs = params.durationMs ?? 300; + const steps = Math.max(2, Math.round(durationMs / 16)); + await electron.dispatchMouseEvent({ + type: "mousePressed", + x: startPx.x, + y: startPx.y, + clickCount: 1, + }); + for (let i = 1; i < steps; i++) { + const t = i / steps; + await electron.dispatchMouseEvent({ + type: "mouseMoved", + x: startPx.x + (endPx.x - startPx.x) * t, + y: startPx.y + (endPx.y - startPx.y) * t, + button: "left", + }); + await sleep(16); + } + await electron.dispatchMouseEvent({ + type: "mouseReleased", + x: endPx.x, + y: endPx.y, + clickCount: 1, + }); + return { dragged: true, timestampMs }; + }, +}; diff --git a/packages/tool-server/src/tools/gesture-scroll/index.ts b/packages/tool-server/src/tools/gesture-scroll/index.ts new file mode 100644 index 00000000..f1887d4b --- /dev/null +++ b/packages/tool-server/src/tools/gesture-scroll/index.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; +import { resolveDevice } from "../../utils/device-info"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const zodSchema = z + .object({ + udid: z + .string() + .describe("Target Electron device id from `list-devices` (electron-cdp-)."), + x: z + .number() + .describe( + "Anchor x: normalized 0.0–1.0 (fraction of window width, not pixels). The wheel events land here — put it over the element you want to scroll." + ), + y: z.number().describe("Anchor y: normalized 0.0–1.0 (fraction of window height, not pixels)."), + deltaX: z + .number() + .optional() + .describe( + "Horizontal scroll distance as a fraction of the window width (e.g. 0.5 = half a window). Positive scrolls content right (reveals content to the right)." + ), + deltaY: z + .number() + .optional() + .describe( + "Vertical scroll distance as a fraction of the window height (e.g. 0.5 = half a window). Positive scrolls content down (reveals content below), like rolling a mouse wheel toward you." + ), + durationMs: z + .number() + .optional() + .describe( + "Spread the scroll over this many milliseconds in wheel-event steps (default 300) so scroll handlers fire progressively." + ), + }) + .refine((p) => (p.deltaX ?? 0) !== 0 || (p.deltaY ?? 0) !== 0, { + message: "Pass a non-zero deltaX and/or deltaY — a scroll with no delta is a no-op.", + }); + +type Params = z.infer; + +interface Result { + scrolled: boolean; + timestampMs: number; +} + +// Electron only. Touch platforms scroll with `gesture-swipe`; a desktop +// renderer scrolls with wheel events, which is exactly what this dispatches. +// Keeping the two as separate tools (instead of overloading swipe) means each +// platform has one obvious scroll verb and the capability gate explains the +// other one. +const capability: ToolCapability = { + electron: { app: true }, +}; + +export const gestureScrollTool: ToolDefinition = { + id: "gesture-scroll", + description: `Scroll content in an Electron app by dispatching mouse-wheel events at a point. Anchor x/y are normalized 0.0–1.0 (fractions of the window, not pixels), same coordinate space as gesture-tap and describe. Deltas are fractions of the window too: deltaY 0.5 scrolls down half a window; negative scrolls back up. +Use when content is below/above the fold (describe shows off-screen elements with zero height) or a list needs scrolling. Electron only — on iOS/Android use gesture-swipe. +Returns { scrolled: true, timestampMs }. Fails if the Electron CDP session is not reachable for the given device.`, + alwaysLoad: true, + searchHint: "scroll wheel list page electron mouse down up content fold", + zodSchema, + capability, + services: (params): Record => ({ + electron: electronCdpRef(resolveDevice(params.udid)), + }), + async execute(services, params) { + const timestampMs = Date.now(); + const electron = services.electron as ElectronCdpApi; + const vp = electron.getViewport(); + const totalDx = (params.deltaX ?? 0) * vp.width; + const totalDy = (params.deltaY ?? 0) * vp.height; + const durationMs = params.durationMs ?? 300; + // Chunk into ~60fps wheel events so the renderer's scroll handlers fire + // progressively, like a human rolling the wheel — one giant delta can + // skip virtualized-list rendering and scroll-linked animations. + const steps = Math.max(1, Math.round(durationMs / 16)); + const point = { x: params.x, y: params.y }; + for (let i = 0; i < steps; i++) { + await electron.server.sendWheel(point, totalDx / steps, totalDy / steps); + if (i < steps - 1) await sleep(16); + } + return { scrolled: true, timestampMs }; + }, +}; diff --git a/packages/tool-server/src/tools/gesture-swipe/index.ts b/packages/tool-server/src/tools/gesture-swipe/index.ts index 27a80f6f..978562ed 100644 --- a/packages/tool-server/src/tools/gesture-swipe/index.ts +++ b/packages/tool-server/src/tools/gesture-swipe/index.ts @@ -25,6 +25,10 @@ interface Result { timestampMs: number; } +// Touch platforms only. A desktop renderer has no touch swipe: a mouse drag +// selects text instead of scrolling, so Electron callers use the dedicated +// `gesture-scroll` tool (wheel-based) and the capability gate rejects this +// one with a clear error rather than silently doing the wrong thing. const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, @@ -32,11 +36,10 @@ const capability: ToolCapability = { export const gestureSwipeTool: ToolDefinition = { id: "gesture-swipe", - description: `Execute a smooth swipe gesture between two points on the device (iOS simulator or Android emulator). All from/to positions are normalized 0.0–1.0 (fractions of screen width/height, not pixels), same as gesture-tap and simulator-server touch. + description: `Execute a smooth swipe / drag touch gesture between two points on the device (iOS simulator or Android emulator). All from/to positions are normalized 0.0–1.0 (fractions of screen width/height, not pixels), same as gesture-tap. Generates interpolated Move events for a natural feel (~60fps). Swipe up (fromY > toY) to scroll content down. -Swipe down (fromY < toY) to scroll content up. -Use when you need to scroll a list, dismiss a modal, or navigate between pages. Returns { swiped: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.`, +Use when you need to scroll a list, dismiss a modal, drag an element, or navigate between pages. Not supported on Electron — use gesture-scroll there instead. Returns { swiped: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.`, alwaysLoad: true, searchHint: "swipe scroll drag pan gesture device simulator emulator touch move", zodSchema, @@ -45,17 +48,16 @@ Use when you need to scroll a list, dismiss a modal, or navigate between pages. simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), async execute(services, params) { - const api = services.simulatorServer as SimulatorServerApi; const duration = params.durationMs ?? 300; + const timestampMs = Date.now(); + const api = services.simulatorServer as SimulatorServerApi; const steps = Math.max(1, Math.round(duration / 16)); - let timestampMs = 0; for (let i = 0; i <= steps; i++) { const t = i / steps; const x = params.fromX + (params.toX - params.fromX) * t; const y = params.fromY + (params.toY - params.fromY) * t; const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; - if (i === 0) timestampMs = Date.now(); sendCommand(api, { cmd: "touch", type, diff --git a/packages/tool-server/src/tools/gesture-tap/index.ts b/packages/tool-server/src/tools/gesture-tap/index.ts index 528fc183..78512971 100644 --- a/packages/tool-server/src/tools/gesture-tap/index.ts +++ b/packages/tool-server/src/tools/gesture-tap/index.ts @@ -1,13 +1,16 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; import { resolveDevice } from "../../utils/device-info"; import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), x: z.number().describe("Normalized horizontal position 0.0–1.0 (left=0, right=1), not pixels"), y: z.number().describe("Normalized vertical position 0.0–1.0 (top=0, bottom=1), not pixels"), }); @@ -22,25 +25,46 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; +async function tapElectron(api: ElectronCdpApi, x: number, y: number): Promise { + const vp = api.getViewport(); + const pxX = Math.max(0, Math.min(vp.width, x * vp.width)); + const pxY = Math.max(0, Math.min(vp.height, y * vp.height)); + await api.dispatchMouseEvent({ type: "mouseMoved", x: pxX, y: pxY }); + await api.dispatchMouseEvent({ type: "mousePressed", x: pxX, y: pxY, clickCount: 1 }); + await sleep(50); + await api.dispatchMouseEvent({ type: "mouseReleased", x: pxX, y: pxY, clickCount: 1 }); +} + export const gestureTapTool: ToolDefinition = { id: "gesture-tap", - description: `Press the device screen (iOS simulator or Android emulator) at normalized coordinates: x and y are fractions of screen width and height in 0.0–1.0 (not pixels), matching simulator-server touch input. -Sends a Down event followed by an Up event at the same point. + description: `Press the device screen (iOS simulator, Android emulator, or Electron app) at normalized coordinates: x and y are fractions of screen width and height in 0.0–1.0 (not pixels). +Sends a Down event followed by an Up event at the same point. For Electron, this dispatches a CDP mouse-press/release on the renderer. Use when you need to tap a button, link, or any tappable element on the screen. -Returns { tapped: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device. -Before tapping, determine the correct coordinates by using discovery tools: describe, native-describe-screen, debugger-component-tree. More information in \`argent-device-interact\` skill`, +Returns { tapped: true, timestampMs }. Fails if the simulator-server / emulator backend / Electron CDP is not reachable for the given device. +Before tapping, determine the correct coordinates by using discovery tools — pick by platform: iOS / Android use \`describe\`, \`native-describe-screen\`, or \`debugger-component-tree\`; Electron uses \`describe\` (the DOM walker), since the native and RN-specific discovery tools don't apply. More information in \`argent-device-interact\` skill`, alwaysLoad: true, - searchHint: "tap press button element device simulator emulator touch down up", + searchHint: "tap press button element device simulator emulator electron touch down up click", zodSchema, capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(services, params) { - const api = services.simulatorServer as SimulatorServerApi; + const device = resolveDevice(params.udid); const timestampMs = Date.now(); + if (device.platform === "electron") { + const electron = services.electron as ElectronCdpApi; + await tapElectron(electron, params.x, params.y); + return { tapped: true, timestampMs }; + } + const api = services.simulatorServer as SimulatorServerApi; sendCommand(api, { cmd: "touch", type: "Down", diff --git a/packages/tool-server/src/tools/keyboard/electron-keys.ts b/packages/tool-server/src/tools/keyboard/electron-keys.ts new file mode 100644 index 00000000..fce1e888 --- /dev/null +++ b/packages/tool-server/src/tools/keyboard/electron-keys.ts @@ -0,0 +1,97 @@ +// CDP `Input.dispatchKeyEvent` translation for the keyboard tool's named-key +// surface. Maps the same string set we accept on iOS/Android to the renderer's +// DOM key + windowsVirtualKeyCode + code values that web pages typically listen +// for. +// +// Why three fields? key drives KeyboardEvent.key, code drives .code, and +// windowsVirtualKeyCode drives the legacy .keyCode/.which. Apps still wired to +// the deprecated keyCode API (e.g. React Native Web's Pressable) need all +// three set or they will see `keyCode === 0` and silently drop the event. + +export interface ElectronNamedKey { + key: string; + code: string; + windowsVirtualKeyCode: number; +} + +export const ELECTRON_NAMED_KEYS: Record = { + "enter": { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, + "return": { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, + "escape": { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }, + "esc": { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }, + "backspace": { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, + "delete": { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }, + "tab": { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }, + "space": { key: " ", code: "Space", windowsVirtualKeyCode: 32 }, + "arrow-right": { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 }, + "arrow-left": { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 }, + "arrow-down": { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 }, + "arrow-up": { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 }, + "f1": { key: "F1", code: "F1", windowsVirtualKeyCode: 112 }, + "f2": { key: "F2", code: "F2", windowsVirtualKeyCode: 113 }, + "f3": { key: "F3", code: "F3", windowsVirtualKeyCode: 114 }, + "f4": { key: "F4", code: "F4", windowsVirtualKeyCode: 115 }, + "f5": { key: "F5", code: "F5", windowsVirtualKeyCode: 116 }, + "f6": { key: "F6", code: "F6", windowsVirtualKeyCode: 117 }, + "f7": { key: "F7", code: "F7", windowsVirtualKeyCode: 118 }, + "f8": { key: "F8", code: "F8", windowsVirtualKeyCode: 119 }, + "f9": { key: "F9", code: "F9", windowsVirtualKeyCode: 120 }, + "f10": { key: "F10", code: "F10", windowsVirtualKeyCode: 121 }, + "f11": { key: "F11", code: "F11", windowsVirtualKeyCode: 122 }, + "f12": { key: "F12", code: "F12", windowsVirtualKeyCode: 123 }, +}; + +/** + * Resolve the CDP descriptor for a single printable character. Returns null for + * characters the keyboard tool doesn't know how to type (control chars beyond + * tab/newline). For letters/digits/punctuation we set windowsVirtualKeyCode + * even though `text` alone would suffice on most pages — apps listening to + * the legacy `keydown.keyCode` need it. + */ +export function charToElectronKey(char: string): { + key: string; + code: string; + text: string; + windowsVirtualKeyCode: number; +} | null { + if (char.length !== 1) return null; + if (char === "\n" || char === "\r") { + return { key: "Enter", code: "Enter", text: "\r", windowsVirtualKeyCode: 13 }; + } + if (char === "\t") { + return { key: "Tab", code: "Tab", text: "\t", windowsVirtualKeyCode: 9 }; + } + const cc = char.charCodeAt(0); + if (cc >= 0x20 && cc <= 0x7e) { + const upper = char.toUpperCase(); + const upperCc = upper.charCodeAt(0); + // Letters: code KeyA..KeyZ, vk = char code of uppercase + if (upperCc >= 65 && upperCc <= 90) { + return { + key: char, + code: `Key${upper}`, + text: char, + windowsVirtualKeyCode: upperCc, + }; + } + // Digits: code Digit0..Digit9, vk = char code 48..57 + if (cc >= 48 && cc <= 57) { + return { + key: char, + code: `Digit${char}`, + text: char, + windowsVirtualKeyCode: cc, + }; + } + // Punctuation / space: rely on `text` for the actual character; code is + // unused by most apps. windowsVirtualKeyCode = 0 since legacy listeners + // for punctuation are rare and the actual character is delivered via text. + return { + key: char, + code: "", + text: char, + windowsVirtualKeyCode: char === " " ? 32 : 0, + }; + } + return null; +} diff --git a/packages/tool-server/src/tools/keyboard/index.ts b/packages/tool-server/src/tools/keyboard/index.ts index ee0309f5..eec27f2b 100644 --- a/packages/tool-server/src/tools/keyboard/index.ts +++ b/packages/tool-server/src/tools/keyboard/index.ts @@ -1,13 +1,17 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; import { resolveDevice } from "../../utils/device-info"; import { charToKeyPress, NAMED_KEYS, SHIFT_KEYCODE } from "./key-codes"; +import { ELECTRON_NAMED_KEYS, charToElectronKey } from "./electron-keys"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), text: z .string() .optional() @@ -33,22 +37,88 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; +async function runElectron(api: ElectronCdpApi, params: Params): Promise { + const delay = params.delayMs ?? 50; + let keysPressed = 0; + + if (params.key) { + const named = ELECTRON_NAMED_KEYS[params.key.toLowerCase()]; + if (!named) { + throw new Error( + `Unknown key "${params.key}". Supported: ${Object.keys(ELECTRON_NAMED_KEYS).join(", ")}` + ); + } + await api.dispatchKeyEvent({ + type: "keyDown", + key: named.key, + code: named.code, + windowsVirtualKeyCode: named.windowsVirtualKeyCode, + }); + await sleep(delay); + await api.dispatchKeyEvent({ + type: "keyUp", + key: named.key, + code: named.code, + windowsVirtualKeyCode: named.windowsVirtualKeyCode, + }); + keysPressed++; + } + + if (params.text) { + for (const char of params.text) { + const desc = charToElectronKey(char); + if (!desc) { + throw new Error(`No CDP key descriptor for character "${char}"`); + } + await api.dispatchKeyEvent({ + type: "keyDown", + key: desc.key, + code: desc.code, + windowsVirtualKeyCode: desc.windowsVirtualKeyCode, + }); + // `char` delivers the actual codepoint to the focused input; without + // this the field receives no value. + await api.dispatchKeyEvent({ type: "char", text: desc.text }); + await api.dispatchKeyEvent({ + type: "keyUp", + key: desc.key, + code: desc.code, + windowsVirtualKeyCode: desc.windowsVirtualKeyCode, + }); + keysPressed++; + await sleep(delay); + } + } + + return { typed: params.text ?? params.key ?? "", keys: keysPressed }; +} + export const keyboardTool: ToolDefinition = { id: "keyboard", - description: `Type text or press special keys on the device (iOS simulator or Android emulator) using keyboard events. + description: `Type text or press special keys on the device (iOS simulator, Android emulator, or Electron app) using keyboard events. Use when you need to enter text or trigger a named key such as enter, escape, or arrow keys. -Returns { typed: string, keys: number }. Fails if an unsupported key name is provided or the simulator-server / emulator backend is not reachable for the given device. +Returns { typed: string, keys: number }. Fails if an unsupported key name is provided or the simulator-server / emulator backend / Electron CDP is not reachable for the given device. - text: types a string character by character (supports uppercase, digits, common punctuation) - key: presses a single named key (enter, escape, backspace, tab, arrow-up/down/left/right, f1–f12) Provide text, key, or both. Use instead of paste when paste is unreliable or unsupported by the focused field.`, zodSchema, capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(services, params) { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + const electron = services.electron as ElectronCdpApi; + return runElectron(electron, params); + } const api = services.simulatorServer as SimulatorServerApi; const delay = params.delayMs ?? 50; let keysPressed = 0; diff --git a/packages/tool-server/src/tools/launch-app/index.ts b/packages/tool-server/src/tools/launch-app/index.ts index ff6cf4c6..5bb4a664 100644 --- a/packages/tool-server/src/tools/launch-app/index.ts +++ b/packages/tool-server/src/tools/launch-app/index.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; +import { electronCdpRef } from "../../blueprints/electron-cdp"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; import { resolveDevice } from "../../utils/device-info"; import type { LaunchAppAndroidServices, LaunchAppIosServices, LaunchAppResult } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { electronImpl, type LaunchAppElectronServices } from "./platforms/electron"; // Android package grammar is `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+`; // iOS bundle ids use the same reverse-DNS shape with dashes allowed. The union @@ -23,19 +25,19 @@ const zodSchema = z.object({ udid: z .string() .min(1) - .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), bundleId: z .string() .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") .describe( - "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name from build.gradle `applicationId` (e.g. com.android.settings)." + "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name from build.gradle `applicationId` (e.g. com.android.settings). Electron: arbitrary tag; the call is a no-op since the renderer is already running." ), activity: z .string() .regex(ACTIVITY_PATTERN, "activity may only contain letters, digits, '.', '_', '-' and '/'") .optional() .describe( - "Android-only: fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). If omitted on Android, the app's default launcher activity is used. Ignored on iOS." + "Android-only: fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). If omitted on Android, the app's default launcher activity is used. Ignored on iOS / Electron." ), }); @@ -44,35 +46,40 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; export const launchAppTool: ToolDefinition = { id: "launch-app", - description: `Open an app by its bundle id (iOS) or package name (Android). + description: `Open an app by its bundle id (iOS) or package name (Android), or confirm the running renderer (Electron). Use when starting any app — prefer this over tapping home-screen / launcher icons. Also prepares the native-devtools injection on iOS before the app starts. -Returns { launched, bundleId }. Fails if the app is not installed on the target device. +Returns { launched, bundleId }. Fails if the app is not installed on the target device (iOS / Android). +For Electron, the app is already running behind a CDP port; this call simply refreshes the cached viewport and acknowledges the bundleId tag. To change the visible route, use \`open-url\`. Common iOS bundle ids: com.apple.MobileSMS, com.apple.mobilesafari, com.apple.Preferences, com.apple.Maps, com.apple.camera, com.apple.Photos, com.apple.mobilemail, com.apple.mobilenotes, com.apple.MobileAddressBook Common Android packages: com.android.settings, com.android.chrome, com.google.android.apps.maps, com.google.android.gm, com.android.vending, com.google.android.dialer, com.google.android.apps.messaging`, alwaysLoad: true, - searchHint: "open start app bundle id package simulator emulator launch", + searchHint: "open start app bundle id package simulator emulator electron launch", zodSchema, capability, - // Only iOS needs the native-devtools service for launch-time injection. - // Resolving it on Android would force the iOS-only blueprint to spin up. + // Only iOS needs the native-devtools service for launch-time injection. Electron needs its CDP session. services: (params): Record => { const device = resolveDevice(params.udid); - return device.platform === "ios" ? { nativeDevtools: nativeDevtoolsRef(device) } : {}; + if (device.platform === "ios") return { nativeDevtools: nativeDevtoolsRef(device) }; + if (device.platform === "electron") return { electron: electronCdpRef(device) }; + return {}; }, execute: dispatchByPlatform< LaunchAppIosServices, LaunchAppAndroidServices, Params, - LaunchAppResult + LaunchAppResult, + LaunchAppElectronServices >({ toolId: "launch-app", capability, ios: iosImpl, android: androidImpl, + electron: electronImpl, }), }; diff --git a/packages/tool-server/src/tools/launch-app/platforms/electron.ts b/packages/tool-server/src/tools/launch-app/platforms/electron.ts new file mode 100644 index 00000000..d527c563 --- /dev/null +++ b/packages/tool-server/src/tools/launch-app/platforms/electron.ts @@ -0,0 +1,32 @@ +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { ElectronCdpApi } from "../../../blueprints/electron-cdp"; +import type { LaunchAppParams, LaunchAppResult } from "../types"; + +export interface LaunchAppElectronServices { + electron: ElectronCdpApi; +} + +/** + * Electron's "app" is the already-running process behind the CDP port. There's + * no concept of installing or launching a separate bundle inside one Electron + * instance — the renderer is already there from `boot-device`. This handler + * therefore acts as a no-op that confirms the connection and returns the + * canonical bundleId passed by the caller, so workflows that always call + * `launch-app` after `boot-device` (matching the iOS / Android pattern) keep + * working without special-casing Electron. + * + * If callers want to navigate the renderer to a route, they should use + * `open-url` instead. + */ +export const electronImpl: PlatformImpl< + LaunchAppElectronServices, + LaunchAppParams, + LaunchAppResult +> = { + handler: async (services, params) => { + // Touch the viewport so a stale cached size doesn't trip the next tap if + // the renderer window was resized between boot-device and launch-app. + await services.electron.refreshViewport(); + return { launched: true, bundleId: params.bundleId }; + }, +}; diff --git a/packages/tool-server/src/tools/network/network-logs.ts b/packages/tool-server/src/tools/network/network-logs.ts index 7e80577c..f1e46227 100644 --- a/packages/tool-server/src/tools/network/network-logs.ts +++ b/packages/tool-server/src/tools/network/network-logs.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { NetworkInspectorApi } from "../../blueprints/network-inspector"; +import { RN_ONLY_TOOL_CAPABILITY } from "../debugger/debugger-service-ref"; import { NETWORK_INTERCEPTOR_SCRIPT, makeNetworkLogReadScript, @@ -75,6 +76,10 @@ Network interception is injected into the JS runtime — it captures fetch() cal Use when inspecting outbound HTTP traffic or debugging API calls in the running app. Fails if the app is not connected or no network interceptor could be injected.`, zodSchema, + // RN-only: the interceptor monkey-patches global.fetch in the Hermes runtime. + // Electron pages should be inspected via Chromium's Network domain instead, + // which is a different mechanism — out of scope for this port. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ inspector: `NetworkInspector:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/network/network-request.ts b/packages/tool-server/src/tools/network/network-request.ts index 66eb22ad..fe5df145 100644 --- a/packages/tool-server/src/tools/network/network-request.ts +++ b/packages/tool-server/src/tools/network/network-request.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../debugger/debugger-service-ref"; import type { NetworkInspectorApi } from "../../blueprints/network-inspector"; import { NETWORK_INTERCEPTOR_SCRIPT, @@ -110,6 +111,8 @@ Returns request/response headers (sensitive headers redacted), status, timing, a Large response bodies are truncated. Use when you need headers, body, or timing for a specific request after listing logs. Returns an error message string if the requestId is not found — use view-network-logs to get valid requestId values.`, zodSchema, + // RN-only: companion to view-network-logs (same injected interceptor). + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ inspector: `NetworkInspector:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/open-url/index.ts b/packages/tool-server/src/tools/open-url/index.ts index 8023cadd..ceaf53e4 100644 --- a/packages/tool-server/src/tools/open-url/index.ts +++ b/packages/tool-server/src/tools/open-url/index.ts @@ -1,19 +1,22 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; +import { resolveDevice } from "../../utils/device-info"; +import { electronCdpRef } from "../../blueprints/electron-cdp"; import type { OpenUrlResult, OpenUrlServices } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { electronImpl, type OpenUrlElectronServices } from "./platforms/electron"; const zodSchema = z.object({ udid: z .string() .min(1) - .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), url: z .string() .describe( - "URL or scheme to open (e.g. https://example.com, messages://, tel:555, geo:37.0,-122.0)." + "URL or scheme to open (e.g. https://example.com, messages://, tel:555, geo:37.0,-122.0). For Electron this navigates the renderer." ), }); @@ -22,21 +25,35 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; export const openUrlTool: ToolDefinition = { id: "open-url", description: `Open a URL or URL scheme on the device. -Use to navigate to a web page or deep-link into an app. +Use to navigate to a web page or deep-link into an app. On Electron, this navigates the primary renderer to the given URL. Cross-platform schemes: https://, tel:, mailto:. iOS also: messages://, settings://, maps://. Android also: geo:, plus any app-specific deep link. -Returns { opened, url }. Fails if no app is registered to handle the URI.`, +Returns { opened, url }. Fails if no app is registered to handle the URI (iOS/Android) or the renderer rejects the navigation (Electron).`, zodSchema, capability, - services: () => ({}), - execute: dispatchByPlatform({ + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return {}; + }, + execute: dispatchByPlatform< + OpenUrlServices, + OpenUrlServices, + Params, + OpenUrlResult, + OpenUrlElectronServices + >({ toolId: "open-url", capability, ios: iosImpl, android: androidImpl, + electron: electronImpl, }), }; diff --git a/packages/tool-server/src/tools/open-url/platforms/electron.ts b/packages/tool-server/src/tools/open-url/platforms/electron.ts new file mode 100644 index 00000000..19878bc8 --- /dev/null +++ b/packages/tool-server/src/tools/open-url/platforms/electron.ts @@ -0,0 +1,17 @@ +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { ElectronCdpApi } from "../../../blueprints/electron-cdp"; +import type { OpenUrlParams, OpenUrlResult } from "../types"; + +export interface OpenUrlElectronServices { + electron: ElectronCdpApi; +} + +export const electronImpl: PlatformImpl = { + handler: async (services, params) => { + await services.electron.navigate(params.url); + // Re-read the viewport — navigating to a route can swap layouts that change + // window.innerWidth/Height (responsive UIs). + await services.electron.refreshViewport(); + return { opened: true, url: params.url }; + }, +}; diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index 04e2a8db..86be2097 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -48,6 +48,9 @@ Call this tool when both profilers were run in parallel on the same session. Returns a markdown report correlating hangs with React commits, memory leaks, and investigation hints. Fails if either react-profiler-analyze or native-profiler-analyze has not been called first.`, zodSchema, + // iOS-only: combines React (Hermes) + iOS native (xctrace) traces. The + // capture half exists on neither Android nor Electron. + capability: { apple: { simulator: true, device: true } }, services: (params) => ({ nativeSession: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), diff --git a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts index d89dc641..aed85c75 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import type { DevToolsFiberCommit, @@ -327,6 +328,8 @@ Use when drilling into specific components or time windows after react-profiler- Returns a markdown table or tree of commit data matching the requested mode. Fails if react-profiler-stop has not been called or no commit data is stored.`, zodSchema, + // RN-only: reads React commit data captured via the React DevTools backend. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params) { const commitTree = await getCommitTree(params.port, params.device_id); diff --git a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts index ee1bde9b..cd466e1a 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { type ProfilerSessionPaths, getCachedProfilerPaths, @@ -348,6 +349,9 @@ Use when investigating JS CPU hotspots or correlating CPU cost with specific com Returns a markdown table of CPU hotspots, call tree, or per-component CPU breakdown. Fails if no CPU profile is stored — run react-profiler-stop first.`, zodSchema, + // RN-only: reads Hermes-format CPU profiles. Electron's V8 sample format is + // different — see the PR description for the follow-up scope. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params) { const sessionPaths = getCachedProfilerPaths(params.port, params.device_id); diff --git a/packages/tool-server/src/tools/profiler/query/profiler-load.ts b/packages/tool-server/src/tools/profiler/query/profiler-load.ts index 6dec57e3..19604231 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -11,6 +11,7 @@ import { type NativeProfilerSessionApi, } from "../../../blueprints/native-profiler-session"; import { resolveDevice } from "../../../utils/device-info"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { readCommitTree } from "../../../utils/react-profiler/debug/dump"; import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; @@ -404,6 +405,10 @@ Modes: Returns a summary of the loaded session or a session list for the list mode. Fails if the session_id is not found or required XML files are missing from disk.`, zodSchema, + // Loads Hermes-format React traces or iOS xctrace XML — neither maps onto + // Electron yet. The gate keeps the error close to the call site instead of + // letting it surface from inside the trace parser. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => { const svcs: Record = {}; if (params.mode === "load_native") { diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index f470ebf9..449b28cb 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -357,6 +357,9 @@ Use when drilling into native hang stacks, thread CPU breakdown, or memory leaks Returns a markdown report with native call stacks, thread weights, or leak details for the selected mode. Fails if native-profiler-analyze has not been run or no parsed trace data is in memory.`, zodSchema, + // iOS-only: reads xctrace output. Android native profiling is on the roadmap; + // Electron has no native trace capture. + capability: { apple: { simulator: true, device: true } }, services: (params) => ({ session: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts index 77164162..ae3142eb 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { promises as fsPromises } from "fs"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { type ProfilerSessionPaths, getCachedProfilerPaths, @@ -86,6 +87,9 @@ is returned by react-profiler-start. Use when the profiling session is complete and you need to interpret the collected data. Fails if react-profiler-stop has not been called or no profiling data is stored.`, zodSchema, + // RN-only: operates on profiler trace files captured via the React DevTools + // backend's commit recording, which is not present on Electron. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params, ctx) { const sessionPaths: ProfilerSessionPaths | undefined = getCachedProfilerPaths( diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-component-source.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-component-source.ts index 7f32a090..178b642a 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-component-source.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-component-source.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { promises as fs } from "fs"; import type { FileInputSpec, ToolDefinition } from "@argent/registry"; import { buildAstIndexWithDiagnostics } from "../../../utils/react-profiler/pipeline/06-resolve/ast-index"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; const zodSchema = z.object({ component_name: z.string().describe("Name of the React component to look up"), @@ -27,6 +28,13 @@ export const reactProfilerComponentSourceTool: ToolDefinition< Call this per-finding after react-profiler-analyze to inspect source before proposing a fix. Returns found: false if the component is not found in user-owned code (e.g. lives in node_modules).`, zodSchema, + // Companion to react-profiler-analyze. Carries the same RN-only capability + // declaration as the rest of react-profiler-* for intent-clarity, even + // though the HTTP gate is a no-op here (the tool takes no device_id, so + // there's nothing for the gate to inspect). An LLM agent reading the tool + // catalogue should see this is paired with the other react-profiler tools + // and not reach for it on an Electron app. + capability: RN_ONLY_TOOL_CAPABILITY, fileInputs, services: () => ({}), async execute(_services, params) { diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts index fd69e261..5ca47025 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import type { HermesProfileNode, @@ -124,6 +125,10 @@ Call react-profiler-stop first. Reads directly from the stored cpuProfile. Returns a markdown table of the top hotspot functions with self-time, total-time, and location. Fails if react-profiler-stop has not been called or no CPU profile is stored.`, zodSchema, + // RN-only: reads a Hermes CPU profile captured via the React profiler + // session. Electron's V8 Profiler emits a different sample format — see the + // PR description for the follow-up scope. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params) { const sessionPaths = getCachedProfilerPaths(params.port, params.device_id); diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts index 340cf8c8..d69ec768 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { REACT_PROFILER_SESSION_NAMESPACE, type ReactProfilerSessionApi, @@ -117,6 +118,8 @@ Use when tracing ancestry of a library component or checking for useMemoCache ho Returns a nested JSON tree of fiber nodes with name, tag, actualDuration, selfBaseDuration, and children. Fails if the React DevTools hook is not present or no fiber roots have been committed yet.`, zodSchema, + // RN-only: walks the fiber tree via the React DevTools backend hook. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ profilerSession: `${REACT_PROFILER_SESSION_NAMESPACE}:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts index 89f53495..e5870f4a 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { REACT_PROFILER_SESSION_NAMESPACE, type ReactProfilerSessionApi, @@ -117,6 +118,8 @@ Returns a markdown table of the top re-rendering components. No profiling sessio Use when you want a quick snapshot of render counts without a full profiling session. Fails if the React DevTools hook is not present in the runtime or the app is not connected.`, zodSchema, + // RN-only: queries the React DevTools backend hook on the live runtime. + capability: RN_ONLY_TOOL_CAPABILITY, services: (params) => ({ profilerSession: `${REACT_PROFILER_SESSION_NAMESPACE}:${params.port}:${params.device_id}`, }), diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts index 107462a3..80f572f9 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts @@ -19,6 +19,7 @@ import { DEFAULT_STALE_THRESHOLD_MS, type ProfilerSessionOwner, } from "../../../utils/react-profiler/session-ownership"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; import { bootstrapFailureMessage, type BootstrapResult, @@ -103,6 +104,10 @@ After starting, ask the user to perform the interaction to profile, then call re Returns { started_at, startedAtEpochMs, hermes_version, detected_architecture } on success, or the already_running payload described above. Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot be established.`, zodSchema, + // RN-only: bootstraps the React DevTools backend and uses Hermes' + // Profiler.start. A CDP-direct CPU profile for Electron is tracked as a + // follow-up; the React commit recording has no Electron analog. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params) { const jsdUrn = `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${params.port}:${params.device_id}`; diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts index fe87909e..212aa33d 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts @@ -9,6 +9,7 @@ import { READ_STATE_SCRIPT, } from "../../../utils/react-profiler/scripts"; import type { ProfilerSessionOwner } from "../../../utils/react-profiler/session-ownership"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), @@ -49,6 +50,8 @@ export function createReactProfilerStatusTool( id: "react-profiler-status", description: `Check the state of the React profiler session without side effects. Use after an interruption (debugger disconnect, unexpected error, agent pause) to decide whether to continue with react-profiler-stop, start a new session, or reconnect the debugger. Ownership is verified server-side against this tool-server's in-memory session — no token-threading is required. Returns { session_status, is_running, current_owner, … }. If this tool-server process restarted after react-profiler-start, status will report 'taken_over'; use react-profiler-start { force: true } to reclaim.`, zodSchema, + // RN-only: companion to react-profiler-start. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params): Promise { const psUrn = `${REACT_PROFILER_SESSION_NAMESPACE}:${params.port}:${params.device_id}`; diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts index f4c827a6..68390e5d 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts @@ -18,6 +18,7 @@ import { STOP_AND_READ_SCRIPT, RESOLVE_FIBER_META_SCRIPT, } from "../../../utils/react-profiler/scripts"; +import { RN_ONLY_TOOL_CAPABILITY } from "../../debugger/debugger-service-ref"; const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), @@ -162,6 +163,8 @@ Returns { duration_ms, sample_count, fiber_renders_captured, total_react_commits When any commit had fibers whose display name could not be resolved at stop time (typically transient components like modals/tooltips/animations that unmounted before stop), the response also includes { unattributed_ms, unattributed_fiber_count, unattributed_commit_count } — these quantify how much work is not accounted for in the per-component breakdown (the per-commit duration itself remains correct). Fails if no active profiling session exists or the CDP connection was lost during recording.`, zodSchema, + // RN-only: companion to react-profiler-start. + capability: RN_ONLY_TOOL_CAPABILITY, services: () => ({}), async execute(_services, params) { const psUrn = `${REACT_PROFILER_SESSION_NAMESPACE}:${params.port}:${params.device_id}`; diff --git a/packages/tool-server/src/tools/run-sequence/index.ts b/packages/tool-server/src/tools/run-sequence/index.ts index fa2708eb..6e54b9f1 100644 --- a/packages/tool-server/src/tools/run-sequence/index.ts +++ b/packages/tool-server/src/tools/run-sequence/index.ts @@ -1,12 +1,17 @@ import { z } from "zod"; import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef } from "@argent/registry"; import { simulatorServerRef } from "../../blueprints/simulator-server"; +import { electronCdpRef } from "../../blueprints/electron-cdp"; import { resolveDevice } from "../../utils/device-info"; +import { assertSupported, UnsupportedOperationError } from "../../utils/capability"; import { sleep, DEFAULT_INTER_STEP_DELAY_MS } from "../../utils/timing"; const ALLOWED_TOOLS = new Set([ "gesture-tap", "gesture-swipe", + "gesture-scroll", + "gesture-drag", "gesture-custom", "gesture-pinch", "gesture-rotate", @@ -19,7 +24,7 @@ const zodSchema = z.object({ udid: z .string() .describe( - "Target device id from `list-devices` (iOS UDID or Android serial) — shared across all steps." + "Target device id from `list-devices` (iOS UDID, Android serial, or Electron id) — shared across all steps." ), steps: z .array( @@ -27,7 +32,7 @@ const zodSchema = z.object({ tool: z .string() .describe( - "Tool name — one of: gesture-tap, gesture-swipe, gesture-custom, gesture-pinch, gesture-rotate, button, keyboard, rotate" + "Tool name — one of: gesture-tap, gesture-swipe, gesture-scroll, gesture-drag, gesture-custom, gesture-pinch, gesture-rotate, button, keyboard, rotate" ), args: z .record(z.string(), z.unknown()) @@ -62,6 +67,7 @@ type RunSequenceResult = { const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; export function createRunSequenceTool( @@ -69,7 +75,7 @@ export function createRunSequenceTool( ): ToolDefinition { return { id: "run-sequence", - description: `Execute multiple device interaction steps in a single call (iOS simulator or Android emulator). + description: `Execute multiple device interaction steps in a single call (iOS simulator, Android emulator, or Electron app). Use when you need sequential actions and do NOT need to observe the screen between them (e.g. scrolling multiple times, typing then pressing enter, rotating back and forth). Returns { completed, total, steps } with per-step results. Fails if an unrecognised tool name is used in a step (error returned at that step, execution stops). @@ -81,16 +87,18 @@ a prior tap), use individual tool calls instead. Allowed tools and their args (udid is auto-injected, do NOT include it in args): - gesture-tap: { x: number, y: number } - gesture-swipe: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } - gesture-custom: { events: [{ type: "Down"|"Move"|"Up", x: number, y: number, x2?: number, y2?: number, delayMs?: number }], interpolate?: number } - gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, angle?: number, durationMs?: number } - gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } - button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } - keyboard: { text?: string, key?: string, delayMs?: number } - rotate: { orientation: "Portrait"|"LandscapeLeft"|"LandscapeRight"|"PortraitUpsideDown" } - -Example — scroll down three times: + gesture-tap: { x: number, y: number } [ios/android/electron] + gesture-swipe: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } [ios/android] + gesture-scroll: { x: number, y: number, deltaX?: number, deltaY?: number, durationMs?: number } [electron only] + gesture-drag: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } [electron only] + gesture-custom: { events: [{ type: "Down"|"Move"|"Up", x: number, y: number, x2?: number, y2?: number, delayMs?: number }], interpolate?: number } [ios/android] + gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, angle?: number, durationMs?: number } [ios only] + gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } [ios only] + button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } [ios/android] + keyboard: { text?: string, key?: string, delayMs?: number } [ios/android/electron] + rotate: { orientation: "Portrait"|"LandscapeLeft"|"LandscapeRight"|"PortraitUpsideDown" } [ios/android] + +Example — scroll down three times (use gesture-scroll with positive deltaY on Electron): { "udid": "", "steps": [ { "tool": "gesture-swipe", "args": { "fromX": 0.5, "fromY": 0.7, "toX": 0.5, "toY": 0.3 } }, { "tool": "gesture-swipe", "args": { "fromX": 0.5, "fromY": 0.7, "toX": 0.5, "toY": 0.3 } }, @@ -109,11 +117,19 @@ Stops on the first error and returns partial results.`, searchHint: "batch sequence multiple gesture steps sequentially", zodSchema, capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + // Eagerly hold a reference to the device's transport service so the + // sub-tool invocations don't pay the spawn / connect cost on the first + // step. iOS / Android use simulator-server; Electron uses CDP. + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(_services, params) { const { udid, steps } = params; + const device = resolveDevice(udid); const results: StepResult[] = []; for (const step of steps) { @@ -125,6 +141,24 @@ Stops on the first error and returns partial results.`, break; } + // Pre-flight the sub-tool's capability gate. Registry.invokeTool does + // NOT call assertSupported (the HTTP layer does), so without this + // check a mobile-only step like `button` on an Electron device would + // descend into the simulator-server blueprint factory and surface as + // a generic 500 instead of a clean "not supported on electron". + const subTool = registry.getTool(step.tool); + if (subTool?.capability) { + try { + assertSupported(step.tool, subTool.capability, device); + } catch (err) { + if (err instanceof UnsupportedOperationError) { + results.push({ tool: step.tool, error: err.message }); + break; + } + throw err; + } + } + try { const toolArgs = { ...step.args, udid }; const result = await registry.invokeTool(step.tool, toolArgs); diff --git a/packages/tool-server/src/tools/screenshot/index.ts b/packages/tool-server/src/tools/screenshot/index.ts index c711b0f4..6eb8193a 100644 --- a/packages/tool-server/src/tools/screenshot/index.ts +++ b/packages/tool-server/src/tools/screenshot/index.ts @@ -1,23 +1,29 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { electronCdpRef, type ElectronCdpApi } from "../../blueprints/electron-cdp"; import { resolveDevice } from "../../utils/device-info"; import { httpScreenshot } from "../../utils/simulator-client"; import { requireArtifacts, type ArtifactHandle } from "../../artifacts"; const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .describe("Target device id from `list-devices` (iOS UDID, Android serial, or Electron id)."), rotation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .optional() - .describe("Orientation override for the screenshot"), + .describe( + "Orientation override for the screenshot (rotates the captured image after Page.captureScreenshot on Electron)." + ), scale: z .number() .min(0.01) .max(1.0) .optional() .describe( - "Scale factor (0.01-1.0). Defaults to ARGENT_SCREENSHOT_SCALE env var, or 0.3 if unset. Use 1.0 only when saving full-resolution PNG artifacts." + "Scale factor (0.01-1.0). Defaults to ARGENT_SCREENSHOT_SCALE env var, or 0.3 if unset for iOS/Android. " + + "On Electron the default is 1.0 (no downscale); pass <1 to opt in. Downscaling on Electron requires the optional `sharp` dependency." ), includeImageInContext: z .boolean() @@ -26,6 +32,12 @@ const zodSchema = z.object({ .describe( "Default true. Set false only when capturing a full-resolution PNG (scale: 1.0) to save as a baseline/current for screenshot-diff — the file is still written, but the image bytes are not attached to the agent context." ), + downscaler: z + .enum(["lanczos3", "box", "bilinear", "nearest"]) + .optional() + .describe( + "Downscaling algorithm when scale<1 on Electron. Defaults to lanczos3 (highest quality). Mirrors sim-server's wire enum." + ), }); type Params = z.infer; @@ -43,22 +55,38 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, }; export const screenshotTool: ToolDefinition = { id: "screenshot", - description: `Capture a screenshot of the device screen (iOS simulator or Android emulator). Returns { url, path }; the MCP adapter renders it as a visible image unless the caller passed includeImageInContext: false. + description: `Capture a screenshot of the device screen (iOS simulator, Android emulator, or Electron app). Returns { url, path }; the MCP adapter renders it as a visible image unless the caller passed includeImageInContext: false. Use when you need a baseline image before an interaction or to inspect the current screen state after a delay. -Fails if the simulator-server / emulator backend is not reachable for the given device.`, +Fails if the simulator-server / emulator backend / Electron CDP is not reachable for the given device.`, alwaysLoad: true, - searchHint: "device simulator emulator screen image capture baseline", + searchHint: "device simulator emulator electron screen image capture baseline", zodSchema, outputHint: "image", capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + return { electron: electronCdpRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(services, params, ctx) { + const device = resolveDevice(params.udid); + if (device.platform === "electron") { + const electron = services.electron as ElectronCdpApi; + const { path } = await electron.captureScreenshot({ + rotation: params.rotation, + scale: params.scale, + downscaler: params.downscaler, + }); + const image = await requireArtifacts(ctx).register(path, { mimeType: "image/png" }); + return { image }; + } const api = services.simulatorServer as SimulatorServerApi; const signal = ctx?.signal ?? AbortSignal.timeout(16_000); const { path } = await httpScreenshot(api, params.rotation, signal, params.scale); diff --git a/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts index 1d914921..aab9134c 100644 --- a/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts +++ b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts @@ -3,11 +3,13 @@ import type { Registry, ToolDefinition } from "@argent/registry"; import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; import { ANDROID_DEVTOOLS_NAMESPACE } from "../../blueprints/android-devtools"; +import { ELECTRON_CDP_NAMESPACE } from "../../blueprints/electron-cdp"; const PREFIXES = [ `${SIMULATOR_SERVER_NAMESPACE}:`, `${NATIVE_DEVTOOLS_NAMESPACE}:`, `${ANDROID_DEVTOOLS_NAMESPACE}:`, + `${ELECTRON_CDP_NAMESPACE}:`, ]; export function createStopAllSimulatorServersTool( @@ -15,7 +17,7 @@ export function createStopAllSimulatorServersTool( ): ToolDefinition { return { id: "stop-all-simulator-servers", - description: `Stop all running simulator-server processes (iOS + Android) and native devtools services and free their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`, + description: `Stop all running simulator-server processes (iOS + Android), native devtools services, and Electron CDP sessions, freeing their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`, services: () => ({}), async execute() { const snapshot = registry.getSnapshot(); diff --git a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts index a9bb5b25..8d927619 100644 --- a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts +++ b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts @@ -2,11 +2,15 @@ import { z } from "zod"; import { ServiceState } from "@argent/registry"; import type { Registry, ToolDefinition } from "@argent/registry"; import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; +import { ELECTRON_CDP_NAMESPACE } from "../../blueprints/electron-cdp"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z .string() - .describe("Target device id (iOS UDID or Android serial) whose simulator-server to stop"), + .describe( + "Target device id (iOS UDID, Android serial, or Electron id) whose transport session to stop" + ), }); export function createStopSimulatorServerTool( @@ -14,12 +18,16 @@ export function createStopSimulatorServerTool( ): ToolDefinition<{ udid: string }, { stopped: boolean; udid: string }> { return { id: "stop-simulator-server", - description: `Stop the simulator-server process for a specific device (iOS UDID or Android serial) and free its resources. Use when you are done interacting with one device but want to keep others running. Returns { stopped, udid }. Fails silently if no server is running for the given UDID.`, + description: `Stop the transport session for a specific device (iOS / Android: simulator-server process; Electron: CDP WebSocket) and free its resources. Use when you are done interacting with one device but want to keep others running. Returns { stopped, udid }. Fails silently if no session is open for the given id.`, zodSchema, services: () => ({}), async execute(_services, params) { const udid = (params as { udid: string }).udid; - const urn = `${SIMULATOR_SERVER_NAMESPACE}:${udid}`; + const namespace = + resolveDevice(udid).platform === "electron" + ? ELECTRON_CDP_NAMESPACE + : SIMULATOR_SERVER_NAMESPACE; + const urn = `${namespace}:${udid}`; const snapshot = registry.getSnapshot(); const entry = snapshot.services.get(urn); if (!entry || entry.state === ServiceState.IDLE) { diff --git a/packages/tool-server/src/utils/capability.ts b/packages/tool-server/src/utils/capability.ts index db29092a..3c3b2ab0 100644 --- a/packages/tool-server/src/utils/capability.ts +++ b/packages/tool-server/src/utils/capability.ts @@ -60,6 +60,20 @@ export class NotImplementedOnPlatformError extends Error { } } +function platformMatrix( + platform: Platform, + capability: ToolCapability +): Record | undefined { + switch (platform) { + case "ios": + return capability.apple; + case "android": + return capability.android; + case "electron": + return capability.electron; + } +} + /** * Throws if the tool's `capability` declaration doesn't include the given * device. A tool with no `capability` is treated as universally supported — @@ -71,11 +85,11 @@ export function assertSupported( device: DeviceInfo ): void { if (!capability) return; - const matrix = device.platform === "ios" ? capability.apple : capability.android; + const matrix = platformMatrix(device.platform, capability); if (!matrix) { throw new UnsupportedOperationError(toolId, device, `no ${device.platform} support declared`); } - const supported = (matrix as Record)[device.kind] === true; + const supported = matrix[device.kind] === true; if (!supported) { throw new UnsupportedOperationError(toolId, device, `kind '${device.kind}' not supported`); } diff --git a/packages/tool-server/src/utils/cross-platform-tool.ts b/packages/tool-server/src/utils/cross-platform-tool.ts index 6f922c7a..a9238521 100644 --- a/packages/tool-server/src/utils/cross-platform-tool.ts +++ b/packages/tool-server/src/utils/cross-platform-tool.ts @@ -5,7 +5,7 @@ import type { ToolDependency, } from "@argent/registry"; import { resolveDevice } from "./device-info"; -import { assertSupported } from "./capability"; +import { assertSupported, NotImplementedOnPlatformError } from "./capability"; import { ensureDeps } from "./check-deps"; /** @@ -46,17 +46,24 @@ export interface PlatformImpl { * `Services` is the shape of services the tool declares — typed so handlers * see real names (e.g. `services.simulatorServer`) instead of the raw * `Record` the registry hands in. + * + * The `electron` branch is optional. When omitted, an electron device triggers + * `NotImplementedOnPlatformError` — the capability gate normally fires first, + * so this only matters for tools that declare electron support without wiring + * a handler. */ export function dispatchByPlatform< IosServices, AndroidServices, Params extends { udid: string }, Result, + ElectronServices = Record, >(opts: { toolId: string; capability: ToolCapability; ios: PlatformImpl; android: PlatformImpl; + electron?: PlatformImpl; }): ( services: Record, params: Params, @@ -71,11 +78,29 @@ export function dispatchByPlatform< } return opts.ios.handler(services as unknown as IosServices, params, device, invokeOptions); } - if (opts.android.requires?.length) { - await ensureDeps(opts.android.requires); + if (device.platform === "android") { + if (opts.android.requires?.length) { + await ensureDeps(opts.android.requires); + } + return opts.android.handler( + services as unknown as AndroidServices, + params, + device, + invokeOptions + ); + } + // electron + if (!opts.electron) { + throw new NotImplementedOnPlatformError({ + toolId: opts.toolId, + platform: "electron", + }); + } + if (opts.electron.requires?.length) { + await ensureDeps(opts.electron.requires); } - return opts.android.handler( - services as unknown as AndroidServices, + return opts.electron.handler( + services as unknown as ElectronServices, params, device, invokeOptions diff --git a/packages/tool-server/src/utils/debugger/cdp-client.ts b/packages/tool-server/src/utils/debugger/cdp-client.ts index 8e085a5b..de31a34c 100644 --- a/packages/tool-server/src/utils/debugger/cdp-client.ts +++ b/packages/tool-server/src/utils/debugger/cdp-client.ts @@ -98,9 +98,15 @@ export class CDPClient { private scripts = new Map(); private enabledDomains = new Set(); private wsUrl: string; + private sendOrigin: boolean; - constructor(wsUrl: string) { + constructor(wsUrl: string, options?: { sendOrigin?: boolean }) { this.wsUrl = wsUrl; + // Default true matches Metro / Expo. Chromium's devtools-target rejects + // upgrade requests that carry an Origin header (since the protocol is + // meant for IDE clients, not pages), so Electron CDP callers must pass + // `sendOrigin: false`. + this.sendOrigin = options?.sendOrigin !== false; } connect(): Promise { @@ -108,10 +114,16 @@ export class CDPClient { // RN >= 0.85 Metro requires an Origin header. Expo's dev server does an // exact match against its serverBaseUrl (127.0.0.1), so we normalize // localhost → 127.0.0.1 in the Origin to satisfy both servers. - const { protocol, host } = new URL(this.wsUrl); - const origin = - (protocol === "wss:" ? "https://" : "http://") + host.replace("localhost", "127.0.0.1"); - const ws = new WebSocket(this.wsUrl, { headers: { Origin: origin } }); + // For Chromium CDP (Electron), the same header triggers a 403, so we + // honour the constructor's `sendOrigin: false`. + let headers: Record | undefined; + if (this.sendOrigin) { + const { protocol, host } = new URL(this.wsUrl); + const origin = + (protocol === "wss:" ? "https://" : "http://") + host.replace("localhost", "127.0.0.1"); + headers = { Origin: origin }; + } + const ws = new WebSocket(this.wsUrl, headers ? { headers } : undefined); this.ws = ws; const onOpen = () => { diff --git a/packages/tool-server/src/utils/device-info.ts b/packages/tool-server/src/utils/device-info.ts index d66ab400..dfedc841 100644 --- a/packages/tool-server/src/utils/device-info.ts +++ b/packages/tool-server/src/utils/device-info.ts @@ -1,27 +1,47 @@ import type { DeviceInfo, DeviceKind, Platform } from "@argent/registry"; /** - * iOS simulator UDID format: 8-4-4-4-12 hex with dashes. Anything else is treated - * as an Android adb serial. We rely on shape rather than listing devices because - * `xcrun simctl list` and `adb devices` are slow enough that classifying a hot - * tool call would dominate its latency. A future enhancement can fall back to - * listing when shape is ambiguous. + * iOS simulator UDID format: 8-4-4-4-12 hex with dashes. Electron devices use the + * `electron-cdp-` prefix so they can be told apart from both iOS UUIDs and + * Android adb serials by shape alone. Anything else is treated as an Android + * serial. Classification is shape-based because `xcrun simctl list` and + * `adb devices` are slow enough that listing on every hot tool call would + * dominate its latency. */ const IOS_UDID_SHAPE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; +export const ELECTRON_ID_PREFIX = "electron-cdp-"; + /** Returns the platform a `udid` belongs to based on its shape. */ export function classifyDevice(udid: string): Platform { + if (udid.startsWith(ELECTRON_ID_PREFIX)) return "electron"; return IOS_UDID_SHAPE.test(udid) ? "ios" : "android"; } /** - * Build a `DeviceInfo` from a raw udid. v1 fills the platform and a default - * kind ('simulator' for iOS, 'emulator' for Android) — platform impls can - * enrich with name/state/sdkLevel via simctl/adb if needed. + * Build a `DeviceInfo` from a raw udid. Fills the platform and a default kind + * ('simulator' for iOS, 'emulator' for Android, 'app' for Electron) — platform + * impls can enrich with name/state/sdkLevel via simctl/adb if needed. */ export function resolveDevice(udid: string): DeviceInfo { const platform = classifyDevice(udid); - const kind: DeviceKind = platform === "ios" ? "simulator" : "emulator"; + const kind: DeviceKind = + platform === "ios" ? "simulator" : platform === "android" ? "emulator" : "app"; return { id: udid, platform, kind }; } + +/** Parses the CDP port out of an electron device id. Returns null if the id is malformed. */ +export function parseElectronCdpPort(udid: string): number | null { + if (!udid.startsWith(ELECTRON_ID_PREFIX)) return null; + const tail = udid.slice(ELECTRON_ID_PREFIX.length); + if (!/^\d+$/.test(tail)) return null; + const port = Number.parseInt(tail, 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) return null; + return port; +} + +/** Build the canonical electron device id from a CDP port. */ +export function electronIdFromPort(port: number): string { + return `${ELECTRON_ID_PREFIX}${port}`; +} diff --git a/packages/tool-server/src/utils/electron-discovery.ts b/packages/tool-server/src/utils/electron-discovery.ts new file mode 100644 index 00000000..769a84dd --- /dev/null +++ b/packages/tool-server/src/utils/electron-discovery.ts @@ -0,0 +1,150 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { ELECTRON_ID_PREFIX, electronIdFromPort } from "./device-info"; +import { ensureCdpReachable, discoverPrimaryPage } from "../blueprints/electron-cdp"; + +export interface ElectronDevice { + platform: "electron"; + /** Canonical Argent device id, e.g. "electron-cdp-19222". */ + id: string; + /** CDP debugging port the Electron process exposed. */ + port: number; + /** Title of the primary page target. */ + title: string; + /** URL the primary page is showing. */ + url: string; + /** Browser version string from /json/version. */ + browser: string | null; + /** Always "Running" — list-devices only surfaces Electron processes whose CDP endpoint is responsive. */ + state: "Running"; +} + +const DEFAULT_PROBE_TIMEOUT_MS = 800; + +function parsePortList(raw: string | undefined): number[] { + if (!raw) return []; + const out: number[] = []; + for (const piece of raw.split(",")) { + const trimmed = piece.trim(); + if (!trimmed) continue; + const n = Number.parseInt(trimmed, 10); + if (Number.isFinite(n) && n > 0 && n <= 65535) out.push(n); + } + return out; +} + +// Process-local set of Electron CDP ports the tool-server has booted. The +// kernel hands out arbitrary high ports, so we cannot rediscover them by +// blind scanning without producing a lot of spurious probes against unrelated +// services. `list-devices` always probes whatever lives in this set plus the +// well-known 9222 and the user-provided env list. +const TRACKED_PORTS = new Set(); + +/** + * Tracked ports are also mirrored to a small file so they survive tool-server + * restarts. Booted Electron apps are detached and deliberately outlive the + * tool-server (which auto-exits on idle), so without persistence every + * restart makes running apps invisible to `list-devices` — the agent then + * boots a duplicate instance. Dead ports are pruned on probe failure, so the + * file self-heals after the app quits. + */ +function portsFilePath(): string { + return ( + process.env.ARGENT_ELECTRON_PORTS_FILE ?? + path.join(os.homedir(), ".argent", "electron-cdp-ports.json") + ); +} + +function loadPersistedPorts(): number[] { + try { + const raw = JSON.parse(fs.readFileSync(portsFilePath(), "utf8")) as unknown; + if (!Array.isArray(raw)) return []; + return raw.filter((p): p is number => typeof p === "number" && p > 0 && p <= 65535); + } catch { + return []; + } +} + +function persistPorts(mutate: (ports: Set) => void): void { + // Best-effort: a persistence failure must never break boot or discovery. + try { + const file = portsFilePath(); + const merged = new Set(loadPersistedPorts()); + mutate(merged); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(Array.from(merged))); + } catch { + // ignore + } +} + +/** Register a port the tool-server spawned. Boot-device calls this. */ +export function trackElectronPort(port: number): void { + TRACKED_PORTS.add(port); + persistPorts((ports) => ports.add(port)); +} + +/** Remove a port. Optional — list-devices auto-prunes ports that fail to probe. */ +export function untrackElectronPort(port: number): void { + TRACKED_PORTS.delete(port); + persistPorts((ports) => ports.delete(port)); +} + +/** + * Candidate ports to probe for a running Electron CDP endpoint. + * - Always includes 9222 (the Chromium default). + * - Honours `ARGENT_ELECTRON_PORTS` (comma-separated list) so users can register custom ports. + * - Includes ports `boot-device` opened in this server process via `trackElectronPort`. + * - Includes ports persisted by previous tool-server processes (apps outlive the server). + */ +export function getCandidateElectronPorts(): number[] { + const fromEnv = parsePortList(process.env.ARGENT_ELECTRON_PORTS); + return Array.from(new Set([9222, ...fromEnv, ...TRACKED_PORTS, ...loadPersistedPorts()])); +} + +async function probePort(port: number, timeoutMs: number): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const version = await ensureCdpReachable(port, ctrl.signal); + const target = await discoverPrimaryPage(port, ctrl.signal); + return { + platform: "electron", + id: electronIdFromPort(port), + port, + title: target.title ?? "", + url: target.url ?? "", + browser: version.Browser ?? null, + state: "Running", + }; + } catch { + // Drop dead tracked ports so list-devices doesn't keep probing a closed app. + TRACKED_PORTS.delete(port); + // Only touch the file when this port was actually persisted — failed + // probes of 9222 / env ports must not create or rewrite it. + if (loadPersistedPorts().includes(port)) { + persistPorts((ports) => ports.delete(port)); + } + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Probe known Electron CDP ports in parallel. Returns one entry per port that + * responded with a usable page target. Failures are silent — non-responsive + * ports are simply not in the result. + */ +export async function discoverElectronDevices(options?: { + timeoutMs?: number; + ports?: number[]; +}): Promise { + const ports = options?.ports ?? getCandidateElectronPorts(); + const timeoutMs = options?.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const probes = await Promise.all(ports.map((p) => probePort(p, timeoutMs))); + return probes.filter((d): d is ElectronDevice => d !== null); +} + +export { ELECTRON_ID_PREFIX, electronIdFromPort }; diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index c05f0fd5..81e0d168 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -3,6 +3,8 @@ import { simulatorServerBlueprint } from "../blueprints/simulator-server"; import { nativeDevtoolsBlueprint } from "../blueprints/native-devtools"; import { androidDevtoolsBlueprint } from "../blueprints/android-devtools"; import { axServiceBlueprint } from "../blueprints/ax-service"; +import { electronCdpBlueprint } from "../blueprints/electron-cdp"; +import { electronJsRuntimeDebuggerBlueprint } from "../blueprints/electron-js-runtime-debugger"; import { nativeDevtoolsStatusTool } from "../tools/native-devtools/native-devtools-status"; import { nativeNetworkLogsTool } from "../tools/native-devtools/native-network-logs"; import { nativeFindViewsTool } from "../tools/native-devtools/native-find-views"; @@ -22,6 +24,8 @@ import { openUrlTool } from "../tools/open-url"; import { screenshotTool } from "../tools/screenshot"; import { gestureTapTool } from "../tools/gesture-tap"; import { gestureSwipeTool } from "../tools/gesture-swipe"; +import { gestureScrollTool } from "../tools/gesture-scroll"; +import { gestureDragTool } from "../tools/gesture-drag"; import { gestureCustomTool } from "../tools/gesture-custom"; import { gesturePinchTool } from "../tools/gesture-pinch"; import { gestureRotateTool } from "../tools/gesture-rotate"; @@ -81,6 +85,8 @@ export function createRegistry(): Registry { registry.registerBlueprint(nativeDevtoolsBlueprint); registry.registerBlueprint(androidDevtoolsBlueprint); registry.registerBlueprint(axServiceBlueprint); + registry.registerBlueprint(electronCdpBlueprint); + registry.registerBlueprint(electronJsRuntimeDebuggerBlueprint); registry.registerTool(listDevicesTool); registry.registerTool(createBootDeviceTool(registry)); @@ -92,6 +98,8 @@ export function createRegistry(): Registry { registry.registerTool(screenshotDiffTool); registry.registerTool(gestureTapTool); registry.registerTool(gestureSwipeTool); + registry.registerTool(gestureScrollTool); + registry.registerTool(gestureDragTool); registry.registerTool(gestureCustomTool); registry.registerTool(gesturePinchTool); registry.registerTool(gestureRotateTool); diff --git a/packages/tool-server/test/boot-device.test.ts b/packages/tool-server/test/boot-device.test.ts index 03bd7f51..77d3f9c0 100644 --- a/packages/tool-server/test/boot-device.test.ts +++ b/packages/tool-server/test/boot-device.test.ts @@ -314,7 +314,7 @@ describe("boot-device — input validation (exclusive udid/avdName)", () => { avdName: "Pixel_7_API_34", } ) - ).rejects.toThrow(/exactly one of `udid` .* or `avdName`/); + ).rejects.toThrow(/exactly one of `udid`/); }); it("rejects when neither udid nor avdName is provided — no target", async () => { diff --git a/packages/tool-server/test/boot-electron-spawn-error.test.ts b/packages/tool-server/test/boot-electron-spawn-error.test.ts new file mode 100644 index 00000000..31b2a13e --- /dev/null +++ b/packages/tool-server/test/boot-electron-spawn-error.test.ts @@ -0,0 +1,316 @@ +/** + * Regression test: `bootElectronApp` must register an `'error'` event handler + * on the spawned electron ChildProcess. Node's `spawn()` returns synchronously + * but emits ENOENT / EACCES / EAGAIN asynchronously as an `'error'` event on + * the next tick. EventEmitter convention: an unhandled `'error'` event escapes + * as an uncaught exception — without a listener, the tool-server would crash + * every time someone called `boot-device` with `electronAppPath` on a host + * without electron on PATH. + * + * The boot promise must also reject (not hang), with a message that names the + * cause and tells the agent how to fix it. + */ + +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const spawnMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: (cmd: string, args: string[], opts: unknown) => spawnMock(cmd, args, opts), + }; +}); + +import { bootElectronApp } from "../src/tools/devices/boot-electron"; + +interface FakeChild extends EventEmitter { + pid: number | undefined; + stderr: EventEmitter; + unref: () => void; + kill: (sig?: NodeJS.Signals) => boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; +} + +function makeFakeChild(opts: { pid?: number | undefined } = {}): FakeChild { + const ee = new EventEmitter() as FakeChild; + ee.pid = "pid" in opts ? opts.pid : 12345; + ee.stderr = new EventEmitter(); + ee.unref = () => {}; + ee.kill = () => true; + ee.exitCode = null; + ee.signalCode = null; + return ee; +} + +let appDir: string; +beforeAll(() => { + // resolveLauncher() fs-checks the app path before spawn, so the test needs + // a real directory on disk. The spawn itself is mocked, so the contents + // don't matter — only the path's existence. + appDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-boot-electron-test-")); + fs.writeFileSync( + path.join(appDir, "package.json"), + JSON.stringify({ name: "fake-electron-app", main: "main.js" }) + ); + fs.writeFileSync(path.join(appDir, "main.js"), "// fake\n"); +}); +afterAll(() => { + if (appDir) fs.rmSync(appDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + spawnMock.mockReset(); +}); + +describe("bootElectronApp — spawn error handling", () => { + it("registers an `error` listener on the spawned electron child", async () => { + const child = makeFakeChild(); + spawnMock.mockReturnValue(child); + + const promise = bootElectronApp({ + appPath: appDir, + port: 19222, + readyTimeoutMs: 100, + }); + promise.catch(() => {}); // detach so the test doesn't hang after assertion + + await new Promise((r) => setTimeout(r, 10)); + + // Without an `error` listener, an emitted error escapes as an uncaught + // exception and crashes the tool-server. + expect(child.listenerCount("error")).toBeGreaterThan(0); + }); + + it("rejects with a clear, actionable message when spawn emits ENOENT", async () => { + const child = makeFakeChild(); + spawnMock.mockReturnValue(child); + + const promise = bootElectronApp({ + appPath: appDir, + port: 19223, + readyTimeoutMs: 30_000, + }); + + // Let the impl subscribe. + await new Promise((r) => setTimeout(r, 10)); + + const err = new Error("spawn ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + child.emit("error", err); + + await expect(promise).rejects.toThrow(/ENOENT/); + await expect(promise).rejects.toThrow(/electron/i); + await expect(promise).rejects.toThrow(/installed.*PATH/i); + }); + + it("rejects (rather than hangs) when spawn emits EACCES", async () => { + const child = makeFakeChild(); + spawnMock.mockReturnValue(child); + + const promise = bootElectronApp({ + appPath: appDir, + port: 19224, + readyTimeoutMs: 30_000, + }); + await new Promise((r) => setTimeout(r, 10)); + + const err = new Error("spawn EACCES") as NodeJS.ErrnoException; + err.code = "EACCES"; + child.emit("error", err); + + await expect(promise).rejects.toThrow(/EACCES/); + }); + + it("still rejects when spawn returns a child with no pid (early-fail path)", async () => { + // Some platforms produce a child without a pid AND no async error event. + // The synchronous "no pid" guard catches that case. + spawnMock.mockReturnValue(makeFakeChild({ pid: undefined })); + + await expect( + bootElectronApp({ + appPath: appDir, + port: 19225, + readyTimeoutMs: 100, + }) + ).rejects.toThrow(/spawn returned without a pid/); + }); + + it("detaches BOTH boot listeners after successful boot — child outliving the function must not leak rejections", async () => { + // The child is detached + unref'd, so it survives beyond bootElectronApp. + // When the user later closes the Electron window (a normal action), the + // child emits 'exit'. Without symmetric cleanup, the earlyExit promise + // would reject "exited with code 0" on an orphan — Node escalates to + // uncaughtException with default --unhandled-rejections=throw. + const child = makeFakeChild(); + spawnMock.mockReturnValue(child); + + // Mock waitForCdpReady → instant success via a real CDP server is overkill; + // emit 'exit' immediately would also work, but we want to test the success + // path. Use a tiny http server on `port` that satisfies ensureCdpReachable + // + discoverPrimaryPage. Or, simpler: fire `Promise.race` to win on the + // ready probe by exposing one. Easiest: just verify the no-leak property + // by triggering a synthetic success — exit fires AFTER we've already + // verified the listeners are detached. + + // Force the boot to complete by emitting `exit` only after we've awaited + // a tick — to win the race naturally we'd need a live HTTP/CDP server, + // which is outside the unit-test scope. Instead, simulate the post-boot + // window by directly stubbing waitForCdpReady via a one-shot HTTP server. + const http = await import("node:http"); + const srv = http.createServer((req, res) => { + res.setHeader("content-type", "application/json"); + if (req.url === "/json/version") { + res.end(JSON.stringify({ "Browser": "Chrome/Test", "Protocol-Version": "1.3" })); + return; + } + if (req.url === "/json/list") { + res.end( + JSON.stringify([ + { + id: "page1", + type: "page", + title: "T", + url: "about:blank", + webSocketDebuggerUrl: "ws://127.0.0.1:1/discard", + }, + ]) + ); + return; + } + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const realPort = (srv.address() as { port: number }).port; + + let unhandled = 0; + const onUnhandled = () => unhandled++; + process.on("unhandledRejection", onUnhandled); + + try { + await bootElectronApp({ + appPath: appDir, + port: realPort, + readyTimeoutMs: 5000, + }); + + // After successful boot, both boot-time listeners MUST be detached. + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + + // Simulate the user closing the Electron window — normal exit code 0. + child.emit("exit", 0, null); + + // And simulate a late stray `'error'` event from the OS layer. + const err = new Error("late ECONNRESET") as NodeJS.ErrnoException; + err.code = "ECONNRESET"; + // 'error' is special — emit() throws if no listener. We're proving + // the absence of a listener is correct here, so the emit itself + // SHOULD throw locally rather than turning into an unhandled + // rejection on an orphan promise. + expect(() => child.emit("error", err)).toThrow(/late ECONNRESET/); + + await new Promise((r) => setImmediate(r)); + expect(unhandled).toBe(0); + } finally { + process.off("unhandledRejection", onUnhandled); + srv.close(); + } + }); + + it("detaches BOTH boot listeners after a FAILURE path too (CDP-ready timeout) — no orphan rejections on the cleanup kill", async () => { + // Companion to the success-path test above. The catch branch in + // bootElectronApp runs detachBootListeners() before killChildEscalating; + // confirm both listeners come off and the synthetic post-kill 'exit' + // doesn't refire any stale handler. + const child = makeFakeChild(); + spawnMock.mockReturnValue(child); + + let unhandled = 0; + const onUnhandled = () => unhandled++; + process.on("unhandledRejection", onUnhandled); + + try { + // No HTTP server on the port → waitForCdpReady will time out fast, + // taking the catch path. earlyExit / spawnError aren't fired by us. + await expect( + bootElectronApp({ + appPath: appDir, + // Pick an unbound port; ensureCdpReachable will fail repeatedly + // until readyTimeoutMs elapses. Don't pick 0 — we want a real + // unreachable port, not OS-assigned ephemeral. + port: 1, + readyTimeoutMs: 100, + }) + ).rejects.toBeInstanceOf(Error); + + // Both listeners must be detached in the catch path. + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + + // killChildEscalating already fired SIGTERM on the (mock) child; in + // production that would cause the kernel to deliver `'exit'` shortly + // after. Simulate it now — the detached listener must NOT chain into + // an earlyExit rejection (which would arrive as an unhandled + // rejection on an orphan promise). + child.emit("exit", null, "SIGTERM"); + + await new Promise((r) => setImmediate(r)); + expect(unhandled).toBe(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("detaches the error listener after the no-pid throw — a deferred 'error' must not become an unhandled rejection", async () => { + // Real-world regression scenario: a hostile platform returns a Child with + // no pid AND fires a deferred 'error' event after spawn returns. Before + // the fix, the error listener would still be attached and would call + // reject() on a promise that nobody is awaiting — Node's default + // --unhandled-rejections=throw would then crash the tool-server. + const child = makeFakeChild({ pid: undefined }); + spawnMock.mockReturnValue(child); + + let unhandledRejections = 0; + const onUnhandled = () => { + unhandledRejections++; + }; + process.on("unhandledRejection", onUnhandled); + + try { + await expect( + bootElectronApp({ + appPath: appDir, + port: 19226, + readyTimeoutMs: 100, + }) + ).rejects.toThrow(/spawn returned without a pid/); + + // After the synchronous throw, no listener should remain on the child. + expect(child.listenerCount("error")).toBe(0); + + // Fire the deferred error now — like Node would. + const err = new Error("late ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + // emit() with no listener on a stock EventEmitter would throw, but the + // test fake-child uses a vanilla EventEmitter, so emit just no-ops when + // there are no listeners on a non-'error' channel. For 'error' events + // specifically Node DOES throw — so guard the emit to confirm the + // listener was actually detached. + expect(() => child.emit("error", err)).toThrow(/late ENOENT/); + + // Give microtasks a tick to surface any unhandled rejection. + await new Promise((r) => setImmediate(r)); + expect(unhandledRejections).toBe(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/packages/tool-server/test/electron-capability.test.ts b/packages/tool-server/test/electron-capability.test.ts new file mode 100644 index 00000000..79631a96 --- /dev/null +++ b/packages/tool-server/test/electron-capability.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { assertSupported, UnsupportedOperationError } from "../src/utils/capability"; +import { resolveDevice } from "../src/utils/device-info"; +import type { ToolCapability } from "@argent/registry"; + +const electronDevice = resolveDevice("electron-cdp-19222"); +const iosDevice = resolveDevice("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"); + +describe("assertSupported (electron)", () => { + it("accepts an electron device when capability declares electron.app", () => { + const cap: ToolCapability = { electron: { app: true } }; + expect(() => assertSupported("test", cap, electronDevice)).not.toThrow(); + }); + + it("rejects an electron device when capability omits electron", () => { + const cap: ToolCapability = { apple: { simulator: true } }; + expect(() => assertSupported("test", cap, electronDevice)).toThrow(UnsupportedOperationError); + }); + + it("rejects an iOS device when capability declares only electron", () => { + const cap: ToolCapability = { electron: { app: true } }; + expect(() => assertSupported("test", cap, iosDevice)).toThrow(UnsupportedOperationError); + }); + + it("rejects an electron device when electron block is empty (kind 'app' not enabled)", () => { + const cap: ToolCapability = { electron: {} }; + expect(() => assertSupported("test", cap, electronDevice)).toThrow(UnsupportedOperationError); + }); +}); diff --git a/packages/tool-server/test/electron-cdp-blueprint.test.ts b/packages/tool-server/test/electron-cdp-blueprint.test.ts new file mode 100644 index 00000000..2db609f0 --- /dev/null +++ b/packages/tool-server/test/electron-cdp-blueprint.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; +import { + ELECTRON_CDP_NAMESPACE, + discoverPrimaryPage, + electronCdpBlueprint, + electronCdpRef, + ensureCdpReachable, +} from "../src/blueprints/electron-cdp"; +import { resolveDevice } from "../src/utils/device-info"; + +interface FakeCdp { + port: number; + http: http.Server; + ws: WebSocketServer; + close: () => Promise; + /** All CDP method names the fake server has received. */ + recordedMethods: string[]; + /** Inject custom replies for specific methods (otherwise default replies are used). */ + setReply: ( + method: string, + payload: Record | ((id: number) => Record) + ) => void; +} + +async function startFakeCdp(): Promise { + const recordedMethods: string[] = []; + const customReplies = new Map< + string, + Record | ((id: number) => Record) + >(); + + const httpSrv = http.createServer((req, res) => { + res.setHeader("content-type", "application/json"); + if (req.url === "/json/version") { + res.end( + JSON.stringify({ + "Browser": "Chrome/Test", + "Protocol-Version": "1.3", + }) + ); + return; + } + if (req.url === "/json/list") { + const port = (httpSrv.address() as AddressInfo).port; + res.end( + JSON.stringify([ + { + id: "page1", + type: "page", + title: "T", + url: "about:blank", + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/page1`, + }, + ]) + ); + return; + } + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve) => httpSrv.listen(0, "127.0.0.1", resolve)); + const port = (httpSrv.address() as AddressInfo).port; + + const wss = new WebSocketServer({ server: httpSrv }); + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + try { + const msg = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: unknown; + }; + recordedMethods.push(msg.method); + let result: unknown; + const custom = customReplies.get(msg.method); + if (custom !== undefined) { + result = typeof custom === "function" ? custom(msg.id) : custom; + } else { + // Default replies — enough to let the blueprint factory finish. + switch (msg.method) { + case "Runtime.evaluate": + // The factory's viewport probe expects a JSON string back. + result = { + result: { type: "string", value: JSON.stringify({ w: 800, h: 600, dpr: 1 }) }, + }; + break; + case "DOM.getDocument": + result = { root: { nodeId: 1, backendNodeId: 100 } }; + break; + case "Page.enable": + case "DOM.enable": + case "Accessibility.enable": + result = {}; + break; + case "Input.dispatchMouseEvent": + case "Input.dispatchKeyEvent": + case "Page.navigate": + result = {}; + break; + case "Page.captureScreenshot": + result = { + // 1x1 transparent PNG + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII=", + }; + break; + case "Accessibility.getFullAXTree": + result = { nodes: [] }; + break; + default: + result = {}; + } + } + ws.send(JSON.stringify({ id: msg.id, result })); + } catch (err) { + // Ignore malformed payloads — the blueprint guards against bad replies on its own. + } + }); + }); + + return { + port, + http: httpSrv, + ws: wss, + recordedMethods, + setReply: (method, payload) => customReplies.set(method, payload), + close: () => + new Promise((resolve) => { + wss.close(() => httpSrv.close(() => resolve())); + }), + }; +} + +const servers: FakeCdp[] = []; + +afterEach(async () => { + for (const s of servers.splice(0)) await s.close(); +}); + +describe("electronCdpBlueprint (smoke)", () => { + it("namespace + URN are stable", () => { + expect(ELECTRON_CDP_NAMESPACE).toBe("ElectronCdp"); + const ref = electronCdpRef(resolveDevice("electron-cdp-9222")); + expect(ref.urn).toBe("ElectronCdp:electron-cdp-9222"); + expect(ref.options.device.platform).toBe("electron"); + }); + + it("discoverPrimaryPage returns the first non-devtools page target", async () => { + const s = await startFakeCdp(); + servers.push(s); + const target = await discoverPrimaryPage(s.port); + expect(target.type).toBe("page"); + expect(target.webSocketDebuggerUrl).toMatch(/ws:\/\/127\.0\.0\.1:\d+\/devtools\/page\/page1/); + }); + + it("ensureCdpReachable returns the /json/version payload", async () => { + const s = await startFakeCdp(); + servers.push(s); + const ver = await ensureCdpReachable(s.port); + expect(ver.Browser).toBe("Chrome/Test"); + }); + + it("factory: connects, primes domains, exposes a working api", async () => { + const s = await startFakeCdp(); + servers.push(s); + const device = resolveDevice(`electron-cdp-${s.port}`); + const instance = await electronCdpBlueprint.factory({}, device, { device }); + + try { + expect(instance.api.port).toBe(s.port); + // Viewport was probed during factory. + expect(instance.api.getViewport()).toEqual({ width: 800, height: 600, devicePixelRatio: 1 }); + + // Dispatch a mouse event — fake server should record it. + await instance.api.dispatchMouseEvent({ + type: "mousePressed", + x: 100, + y: 50, + clickCount: 1, + }); + expect(s.recordedMethods).toContain("Input.dispatchMouseEvent"); + + // Screenshot — fake server returns a tiny PNG, we expect a real file + // path in the unified media dir maintained by the electron-server. + const shot = await instance.api.captureScreenshot(); + expect(shot.path).toMatch(/argent-electron-media/); + expect(shot.path).toMatch(/argent-screenshot-/); + expect(shot.url).toMatch(/^file:\/\//); + } finally { + await instance.dispose(); + } + }); + + it("factory can synthesize the device from a string URN payload when no options.device is given", async () => { + // This path matters for transitive dep resolution — see the registry's + // _resolve, which only forwards the URN string into the factory, not the + // ServiceRef options. The ElectronJsRuntimeDebugger blueprint depends on + // ElectronCdp via getDependencies and reaches this branch. + const s = await startFakeCdp(); + servers.push(s); + const payload = `electron-cdp-${s.port}`; + const instance = await electronCdpBlueprint.factory( + {}, + payload as unknown as ReturnType, + undefined as unknown as Record + ); + try { + expect(instance.api.port).toBe(s.port); + } finally { + await instance.dispose(); + } + }); + + it("factory rejects when neither options.device nor a valid URN payload is given", async () => { + await expect( + electronCdpBlueprint.factory( + {}, + undefined as unknown as ReturnType, + undefined as unknown as Record + ) + ).rejects.toThrow(/could not determine the device/); + }); + + it("factory rejects when options.device.id disagrees with the URN payload", async () => { + const s = await startFakeCdp(); + servers.push(s); + const device = resolveDevice(`electron-cdp-${s.port}`); + const otherPayload = `electron-cdp-${s.port + 1}`; + await expect( + electronCdpBlueprint.factory( + {}, + otherPayload as unknown as ReturnType, + { device } + ) + ).rejects.toThrow(/disagrees with URN payload/); + }); +}); diff --git a/packages/tool-server/test/electron-debugger-dispatch.test.ts b/packages/tool-server/test/electron-debugger-dispatch.test.ts new file mode 100644 index 00000000..04ea6893 --- /dev/null +++ b/packages/tool-server/test/electron-debugger-dispatch.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + DEBUGGER_TOOL_CAPABILITY, + RN_ONLY_TOOL_CAPABILITY, + debuggerServiceRef, +} from "../src/tools/debugger/debugger-service-ref"; +import { ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE } from "../src/blueprints/electron-js-runtime-debugger"; +import { assertSupported, UnsupportedOperationError } from "../src/utils/capability"; +import { resolveDevice } from "../src/utils/device-info"; + +// All tools that must reject Electron at the HTTP capability gate. Pull the +// ToolDefinition directly so any future drift (someone re-adds an `electron:` +// block on one of these) breaks this single test instead of slipping into a +// release. Kept exhaustive on purpose — a per-tool assertion is cheap and the +// list is the contract. +import { debuggerComponentTreeTool } from "../src/tools/debugger/debugger-component-tree"; +import { debuggerReloadMetroTool } from "../src/tools/debugger/debugger-reload-metro"; +import { debuggerInspectElementTool } from "../src/tools/debugger/debugger-inspect-element"; +import { networkLogsTool } from "../src/tools/network/network-logs"; +import { networkRequestTool } from "../src/tools/network/network-request"; +import { reactProfilerAnalyzeTool } from "../src/tools/profiler/react/react-profiler-analyze"; +import { reactProfilerComponentSourceTool } from "../src/tools/profiler/react/react-profiler-component-source"; +import { reactProfilerCpuSummaryTool } from "../src/tools/profiler/react/react-profiler-cpu-summary"; +import { reactProfilerFiberTreeTool } from "../src/tools/profiler/react/react-profiler-fiber-tree"; +import { reactProfilerRendersTool } from "../src/tools/profiler/react/react-profiler-renders"; +import { profilerCpuQueryTool } from "../src/tools/profiler/query/profiler-cpu-query"; +import { profilerCommitQueryTool } from "../src/tools/profiler/query/profiler-commit-query"; +import { profilerStackQueryTool } from "../src/tools/profiler/query/profiler-stack-query"; +import { profilerLoadTool } from "../src/tools/profiler/query/profiler-load"; +import { profilerCombinedReportTool } from "../src/tools/profiler/combined/profiler-combined-report"; + +const ELECTRON_ID = "electron-cdp-19222"; +const IOS_ID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; +const ANDROID_ID = "emulator-5554"; + +describe("debuggerServiceRef — platform dispatch", () => { + it("routes an Electron device id to the ElectronJsRuntimeDebugger blueprint", () => { + const ref = debuggerServiceRef({ port: 8081, device_id: ELECTRON_ID }); + expect(ref).toMatchObject({ + urn: `${ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE}:${ELECTRON_ID}`, + options: { device: resolveDevice(ELECTRON_ID) }, + }); + }); + + it("routes an iOS UDID to the Metro-driven JsRuntimeDebugger blueprint", () => { + const ref = debuggerServiceRef({ port: 8081, device_id: IOS_ID }); + expect(ref).toBe(`JsRuntimeDebugger:8081:${IOS_ID}`); + }); + + it("routes an Android serial to the Metro-driven JsRuntimeDebugger blueprint", () => { + const ref = debuggerServiceRef({ port: 8082, device_id: ANDROID_ID }); + expect(ref).toBe(`JsRuntimeDebugger:8082:${ANDROID_ID}`); + }); + + it("tolerates a missing device_id — falls back to Metro URN so existing callers don't crash", () => { + // Mirrors the original template-literal behavior: `JsRuntimeDebugger:8081:undefined` + // is ugly but doesn't blow up at the dispatch site. Pre-electron tests + // hit this path and relied on it. + const ref = debuggerServiceRef({ port: 8081 }); + expect(typeof ref).toBe("string"); + expect(ref as string).toMatch(/^JsRuntimeDebugger:8081:/); + }); +}); + +describe("debugger tool capability gating — electron", () => { + const electronDevice = resolveDevice(ELECTRON_ID); + const iosDevice = resolveDevice(IOS_ID); + + it("DEBUGGER_TOOL_CAPABILITY admits an Electron device (ported tools)", () => { + expect(() => + assertSupported("debugger-evaluate", DEBUGGER_TOOL_CAPABILITY, electronDevice) + ).not.toThrow(); + }); + + it("DEBUGGER_TOOL_CAPABILITY still admits iOS — port did not regress mobile support", () => { + expect(() => + assertSupported("debugger-evaluate", DEBUGGER_TOOL_CAPABILITY, iosDevice) + ).not.toThrow(); + }); + + it("RN_ONLY_TOOL_CAPABILITY rejects an Electron device (locked-out tools)", () => { + expect(() => + assertSupported("debugger-component-tree", RN_ONLY_TOOL_CAPABILITY, electronDevice) + ).toThrow(UnsupportedOperationError); + }); + + it("RN_ONLY_TOOL_CAPABILITY's rejection message names the tool and platform", () => { + try { + assertSupported("react-profiler-renders", RN_ONLY_TOOL_CAPABILITY, electronDevice); + throw new Error("expected throw"); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toContain("react-profiler-renders"); + expect(msg).toContain("electron"); + expect(msg).toContain("app"); + } + }); +}); + +describe("RN-only tool registry — every locked tool actually rejects Electron", () => { + const electronDevice = resolveDevice("electron-cdp-19222"); + + // Source of truth for what must stay locked. If a tool is added/removed + // here, the maintainer is making an explicit Electron-support decision. + const LOCKED_TOOLS = [ + debuggerComponentTreeTool, + debuggerReloadMetroTool, + debuggerInspectElementTool, + networkLogsTool, + networkRequestTool, + reactProfilerAnalyzeTool, + reactProfilerComponentSourceTool, + reactProfilerCpuSummaryTool, + reactProfilerFiberTreeTool, + reactProfilerRendersTool, + profilerCpuQueryTool, + profilerCommitQueryTool, + profilerStackQueryTool, + profilerLoadTool, + profilerCombinedReportTool, + ]; + + it.each(LOCKED_TOOLS.map((t) => [t.id, t] as const))( + "%s declares a capability and rejects Electron", + (_id, tool) => { + expect(tool.capability).toBeDefined(); + expect(() => assertSupported(tool.id, tool.capability!, electronDevice)).toThrow( + UnsupportedOperationError + ); + } + ); + + it("matches the spec count — exactly 15 device-bound RN/iOS tools are locked", () => { + // Add to LOCKED_TOOLS above when locking a new tool; this guards against + // silent omissions. react-profiler-{start,stop,status} are factory-built + // and not exported as plain ToolDefinitions, so they're absent here even + // though they're locked — counted separately in the PR description. + expect(LOCKED_TOOLS).toHaveLength(15); + }); +}); diff --git a/packages/tool-server/test/electron-device-info.test.ts b/packages/tool-server/test/electron-device-info.test.ts new file mode 100644 index 00000000..7835a17c --- /dev/null +++ b/packages/tool-server/test/electron-device-info.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { + classifyDevice, + electronIdFromPort, + parseElectronCdpPort, + resolveDevice, +} from "../src/utils/device-info"; + +describe("classifyDevice (electron)", () => { + it("classifies electron-cdp- ids as electron", () => { + expect(classifyDevice("electron-cdp-9222")).toBe("electron"); + expect(classifyDevice("electron-cdp-1024")).toBe("electron"); + }); + + it("does not confuse electron with android adb serials", () => { + expect(classifyDevice("emulator-5554")).toBe("android"); + expect(classifyDevice("electron-cdp-9222")).not.toBe("android"); + }); + + it("does not confuse electron with iOS UDIDs", () => { + expect(classifyDevice("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")).toBe("ios"); + }); +}); + +describe("resolveDevice (electron)", () => { + it("returns electron+app for a CDP id", () => { + const d = resolveDevice("electron-cdp-19222"); + expect(d.platform).toBe("electron"); + expect(d.kind).toBe("app"); + expect(d.id).toBe("electron-cdp-19222"); + }); +}); + +describe("parseElectronCdpPort", () => { + it("extracts the numeric port", () => { + expect(parseElectronCdpPort("electron-cdp-9222")).toBe(9222); + expect(parseElectronCdpPort("electron-cdp-65000")).toBe(65000); + }); + it("returns null for non-electron ids", () => { + expect(parseElectronCdpPort("emulator-5554")).toBeNull(); + expect(parseElectronCdpPort("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")).toBeNull(); + }); + it("returns null for malformed electron ids", () => { + expect(parseElectronCdpPort("electron-cdp-")).toBeNull(); + expect(parseElectronCdpPort("electron-cdp-abc")).toBeNull(); + expect(parseElectronCdpPort("electron-cdp-99999")).toBeNull(); + expect(parseElectronCdpPort("electron-cdp-0")).toBeNull(); + }); +}); + +describe("electronIdFromPort", () => { + it("round-trips through parseElectronCdpPort", () => { + const id = electronIdFromPort(19222); + expect(id).toBe("electron-cdp-19222"); + expect(parseElectronCdpPort(id)).toBe(19222); + }); +}); diff --git a/packages/tool-server/test/electron-discovery.test.ts b/packages/tool-server/test/electron-discovery.test.ts new file mode 100644 index 00000000..0f69282a --- /dev/null +++ b/packages/tool-server/test/electron-discovery.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from "vitest"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; +import { AddressInfo } from "node:net"; +import { + discoverElectronDevices, + getCandidateElectronPorts, + trackElectronPort, + untrackElectronPort, +} from "../src/utils/electron-discovery"; + +interface FakeCdpServer { + port: number; + close: () => Promise; +} + +async function startFakeCdpServer(options?: { + responses?: { + version?: number | object; + list?: number | object; + }; +}): Promise { + const server = http.createServer((req, res) => { + if (req.url === "/json/version") { + const r = options?.responses?.version ?? { + Browser: "Chrome/148.0.7778.97", + webSocketDebuggerUrl: `ws://127.0.0.1:0/devtools/browser/x`, + }; + if (typeof r === "number") { + res.statusCode = r; + res.end(); + return; + } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(r)); + return; + } + if (req.url === "/json/list") { + const r = options?.responses?.list ?? [ + { + id: "abc", + type: "page", + title: "Test Page", + url: "file:///tmp/index.html", + webSocketDebuggerUrl: `ws://127.0.0.1:0/devtools/page/abc`, + }, + ]; + if (typeof r === "number") { + res.statusCode = r; + res.end(); + return; + } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(r)); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + return { + port, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +const portsToCleanup: number[] = []; +const serversToCleanup: FakeCdpServer[] = []; + +// Redirect port persistence to a throwaway file so tests never touch the +// real ~/.argent/electron-cdp-ports.json on a developer machine or CI runner. +const TEST_PORTS_FILE = path.join(os.tmpdir(), `argent-test-electron-ports-${process.pid}.json`); + +beforeAll(() => { + process.env.ARGENT_ELECTRON_PORTS_FILE = TEST_PORTS_FILE; +}); + +afterAll(() => { + delete process.env.ARGENT_ELECTRON_PORTS_FILE; + try { + fs.unlinkSync(TEST_PORTS_FILE); + } catch { + // never created — fine + } +}); + +afterEach(async () => { + for (const p of portsToCleanup.splice(0)) untrackElectronPort(p); + for (const s of serversToCleanup.splice(0)) await s.close(); +}); + +describe("getCandidateElectronPorts", () => { + it("always includes 9222", () => { + const ports = getCandidateElectronPorts(); + expect(ports).toContain(9222); + }); + + it("includes tracked ports added via trackElectronPort", () => { + trackElectronPort(54321); + portsToCleanup.push(54321); + expect(getCandidateElectronPorts()).toContain(54321); + }); + + it("removes tracked ports via untrackElectronPort", () => { + trackElectronPort(54322); + expect(getCandidateElectronPorts()).toContain(54322); + untrackElectronPort(54322); + expect(getCandidateElectronPorts()).not.toContain(54322); + }); +}); + +describe("discoverElectronDevices", () => { + it("finds a fake CDP endpoint when its port is tracked", async () => { + const server = await startFakeCdpServer(); + serversToCleanup.push(server); + trackElectronPort(server.port); + portsToCleanup.push(server.port); + + const devices = await discoverElectronDevices({ timeoutMs: 1500 }); + const ours = devices.find((d) => d.port === server.port); + expect(ours).toBeDefined(); + expect(ours?.platform).toBe("electron"); + expect(ours?.id).toBe(`electron-cdp-${server.port}`); + expect(ours?.title).toBe("Test Page"); + expect(ours?.url).toBe("file:///tmp/index.html"); + expect(ours?.browser).toMatch(/Chrome/); + expect(ours?.state).toBe("Running"); + }); + + it("returns no entry for a non-responsive port", async () => { + // Tracked but no server bound + trackElectronPort(1); + portsToCleanup.push(1); + const devices = await discoverElectronDevices({ timeoutMs: 300, ports: [1] }); + expect(devices).toEqual([]); + }); + + it("filters out ports with no page targets", async () => { + const server = await startFakeCdpServer({ + responses: { + list: [ + { id: "x", type: "service_worker", title: "", url: "", webSocketDebuggerUrl: "ws://x" }, + ], + }, + }); + serversToCleanup.push(server); + const devices = await discoverElectronDevices({ timeoutMs: 1500, ports: [server.port] }); + expect(devices).toEqual([]); + }); + + it("untracks a port after it stops responding", async () => { + const server = await startFakeCdpServer(); + trackElectronPort(server.port); + portsToCleanup.push(server.port); + + // First probe succeeds — port stays tracked. + let devices = await discoverElectronDevices({ timeoutMs: 1500 }); + expect(devices.some((d) => d.port === server.port)).toBe(true); + + // Close the server, probe again — port should be untracked. + await server.close(); + devices = await discoverElectronDevices({ timeoutMs: 300 }); + expect(devices.some((d) => d.port === server.port)).toBe(false); + expect(getCandidateElectronPorts()).not.toContain(server.port); + }); +}); + +describe("port persistence across tool-server restarts", () => { + // Booted Electron apps are detached and outlive the tool-server (which + // auto-exits on idle). A fresh module instance — same as a fresh process — + // must rediscover them from the persisted file. + it("a fresh module instance sees ports tracked by a previous one", async () => { + trackElectronPort(43210); + portsToCleanup.push(43210); + + vi.resetModules(); + const fresh = await import("../src/utils/electron-discovery"); + expect(fresh.getCandidateElectronPorts()).toContain(43210); + }); + + it("a dead persisted port is pruned from the file after a failed probe", async () => { + trackElectronPort(43211); + portsToCleanup.push(43211); + expect(JSON.parse(fs.readFileSync(TEST_PORTS_FILE, "utf8"))).toContain(43211); + + // Nothing listens on 43211 — the probe fails and prunes it everywhere. + await discoverElectronDevices({ timeoutMs: 300, ports: [43211] }); + expect(JSON.parse(fs.readFileSync(TEST_PORTS_FILE, "utf8"))).not.toContain(43211); + expect(getCandidateElectronPorts()).not.toContain(43211); + }); + + it("untrackElectronPort removes the port from the persisted file", () => { + trackElectronPort(43212); + expect(JSON.parse(fs.readFileSync(TEST_PORTS_FILE, "utf8"))).toContain(43212); + untrackElectronPort(43212); + expect(JSON.parse(fs.readFileSync(TEST_PORTS_FILE, "utf8"))).not.toContain(43212); + }); + + it("ignores a corrupt persistence file", () => { + fs.writeFileSync(TEST_PORTS_FILE, "not json{{{"); + expect(() => getCandidateElectronPorts()).not.toThrow(); + expect(getCandidateElectronPorts()).toContain(9222); + // Tracking after corruption rewrites the file cleanly. + trackElectronPort(43213); + portsToCleanup.push(43213); + expect(JSON.parse(fs.readFileSync(TEST_PORTS_FILE, "utf8"))).toContain(43213); + }); +}); diff --git a/packages/tool-server/test/electron-dispatch.test.ts b/packages/tool-server/test/electron-dispatch.test.ts new file mode 100644 index 00000000..fff6307f --- /dev/null +++ b/packages/tool-server/test/electron-dispatch.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { dispatchByPlatform } from "../src/utils/cross-platform-tool"; +import { NotImplementedOnPlatformError } from "../src/utils/capability"; +import { __resetDepCacheForTests } from "../src/utils/check-deps"; +import type { ToolCapability } from "@argent/registry"; + +const capability: ToolCapability = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + electron: { app: true }, +}; + +const iosUdid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; +const androidUdid = "emulator-5554"; +const electronUdid = "electron-cdp-19222"; + +beforeEach(() => { + __resetDepCacheForTests(); +}); + +describe("dispatchByPlatform (electron branch)", () => { + it("routes electron udids to the electron handler", async () => { + const ios = vi.fn().mockResolvedValue("ios"); + const android = vi.fn().mockResolvedValue("android"); + const electron = vi.fn().mockResolvedValue("electron"); + const execute = dispatchByPlatform< + Record, + Record, + { udid: string }, + string, + Record + >({ + toolId: "test", + capability, + ios: { handler: ios }, + android: { handler: android }, + electron: { handler: electron }, + }); + expect(await execute({}, { udid: electronUdid })).toBe("electron"); + expect(ios).not.toHaveBeenCalled(); + expect(android).not.toHaveBeenCalled(); + expect(electron).toHaveBeenCalledOnce(); + }); + + it("still routes ios / android correctly when an electron branch exists", async () => { + const ios = vi.fn().mockResolvedValue("ios"); + const android = vi.fn().mockResolvedValue("android"); + const electron = vi.fn().mockResolvedValue("electron"); + const execute = dispatchByPlatform< + Record, + Record, + { udid: string }, + string, + Record + >({ + toolId: "test", + capability, + ios: { handler: ios }, + android: { handler: android }, + electron: { handler: electron }, + }); + expect(await execute({}, { udid: iosUdid })).toBe("ios"); + expect(await execute({}, { udid: androidUdid })).toBe("android"); + expect(electron).not.toHaveBeenCalled(); + }); + + it("throws NotImplementedOnPlatformError on electron when no electron branch is wired", async () => { + const execute = dispatchByPlatform< + Record, + Record, + { udid: string }, + string + >({ + toolId: "ios-android-only", + capability, + ios: { handler: async () => "ios" }, + android: { handler: async () => "android" }, + }); + await expect(execute({}, { udid: electronUdid })).rejects.toBeInstanceOf( + NotImplementedOnPlatformError + ); + }); +}); diff --git a/packages/tool-server/test/electron-drag.test.ts b/packages/tool-server/test/electron-drag.test.ts new file mode 100644 index 00000000..ab6af806 --- /dev/null +++ b/packages/tool-server/test/electron-drag.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from "vitest"; +import { gestureDragTool } from "../src/tools/gesture-drag"; +import { assertSupported, UnsupportedOperationError } from "../src/utils/capability"; +import { resolveDevice } from "../src/utils/device-info"; + +// gesture-drag is the third electron verb: swipe = touch (ios/android), +// scroll = wheel (electron), drag = left-button mouse drag (electron). +// These tests pin the press → interpolated moves → release sequence and +// the electron-only capability fence. + +const electronDevice = resolveDevice("electron-cdp-19222"); +const iosDevice = resolveDevice("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"); +const androidDevice = resolveDevice("emulator-5554"); + +function fakeElectronApi() { + return { + getViewport: () => ({ width: 800, height: 600, devicePixelRatio: 2 }), + dispatchMouseEvent: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("gesture-drag", () => { + it("presses at the start, interpolates moves, releases at the end (viewport px)", async () => { + const api = fakeElectronApi(); + const result = await gestureDragTool.execute( + { electron: api } as never, + { + udid: "electron-cdp-19222", + fromX: 0.25, + fromY: 0.5, + toX: 0.75, + toY: 0.5, + durationMs: 64, + } as never + ); + expect(result.dragged).toBe(true); + + const calls = api.dispatchMouseEvent.mock.calls.map((c) => c[0] as Record); + expect(calls[0]).toMatchObject({ type: "mousePressed", x: 0.25 * 800, y: 0.5 * 600 }); + expect(calls[calls.length - 1]).toMatchObject({ + type: "mouseReleased", + x: 0.75 * 800, + y: 0.5 * 600, + }); + + const moves = calls.slice(1, -1); + expect(moves.length).toBeGreaterThan(0); + for (const move of moves) { + expect(move.type).toBe("mouseMoved"); + expect(move.button).toBe("left"); + // Every interpolated point stays on the straight line between the ends. + expect(move.x as number).toBeGreaterThan(0.25 * 800); + expect(move.x as number).toBeLessThan(0.75 * 800); + expect(move.y).toBeCloseTo(0.5 * 600, 5); + } + }); + + it("is electron-only: capability gate rejects iOS and Android targets", () => { + expect(() => + assertSupported("gesture-drag", gestureDragTool.capability!, electronDevice) + ).not.toThrow(); + expect(() => assertSupported("gesture-drag", gestureDragTool.capability!, iosDevice)).toThrow( + UnsupportedOperationError + ); + expect(() => + assertSupported("gesture-drag", gestureDragTool.capability!, androidDevice) + ).toThrow(UnsupportedOperationError); + }); +}); diff --git a/packages/tool-server/test/electron-format-tree.test.ts b/packages/tool-server/test/electron-format-tree.test.ts new file mode 100644 index 00000000..d5513633 --- /dev/null +++ b/packages/tool-server/test/electron-format-tree.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { formatDescribeTree } from "../src/tools/describe/format-tree"; +import type { DescribeNode } from "../src/tools/describe/contract"; + +const tree: DescribeNode = { + role: "html", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: [ + { + role: "body", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: [ + { + role: "h1", + frame: { x: 0, y: 0, width: 1, height: 0.1 }, + children: [], + value: "Hello World", + identifier: "title", + }, + { + role: "button", + frame: { x: 0, y: 0.2, width: 0.2, height: 0.05 }, + children: [], + label: "Click me", + identifier: "go", + clickable: true, + }, + ], + }, + ], +}; + +describe("formatDescribeTree (cdp-dom)", () => { + it("renders nested mode and shows descendants beyond depth 1", () => { + const out = formatDescribeTree(tree, { source: "cdp-dom" }); + expect(out).toContain("Mode: nested"); + // Body, h1, and button must all appear — flat mode would only emit body. + expect(out).toContain("body"); + expect(out).toContain("h1"); + expect(out).toContain('"Click me"'); + expect(out).toContain('id="go"'); + expect(out).toContain("clickable"); + // The h1 has value "Hello World" — must surface in the rendering. + expect(out).toContain("Hello World"); + }); +}); diff --git a/packages/tool-server/test/electron-js-runtime-debugger.test.ts b/packages/tool-server/test/electron-js-runtime-debugger.test.ts new file mode 100644 index 00000000..1484a873 --- /dev/null +++ b/packages/tool-server/test/electron-js-runtime-debugger.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TypedEventEmitter } from "@argent/registry"; +import { + electronJsRuntimeDebuggerBlueprint, + electronJsRuntimeDebuggerRef, + ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE, +} from "../src/blueprints/electron-js-runtime-debugger"; +import { resolveDevice } from "../src/utils/device-info"; +import type { ElectronCdpApi } from "../src/blueprints/electron-cdp"; +import type { CDPClientEvents } from "../src/utils/debugger/cdp-client"; + +function makeFakeElectronCdpApi(): { + api: ElectronCdpApi; + events: TypedEventEmitter; + sendSpy: ReturnType; + addBindingSpy: ReturnType; +} { + const events = new TypedEventEmitter(); + const sendSpy = vi.fn().mockResolvedValue({}); + const addBindingSpy = vi.fn().mockResolvedValue(undefined); + const cdp = { + events, + isConnected: () => true, + send: sendSpy, + evaluate: vi.fn().mockResolvedValue(null), + addBinding: addBindingSpy, + getLoadedScripts: () => new Map(), + getEnabledDomains: () => new Set(), + }; + // Cast through unknown — the blueprint only touches `cdp`, `port`, and + // the events the test exercises, so a partial fake is fine. + const api = { + port: 19222, + cdp, + } as unknown as ElectronCdpApi; + return { api, events, sendSpy, addBindingSpy }; +} + +describe("ElectronJsRuntimeDebugger blueprint", () => { + const electronDevice = resolveDevice("electron-cdp-19222"); + + it("namespace + URN + ref are stable", () => { + expect(ELECTRON_JS_RUNTIME_DEBUGGER_NAMESPACE).toBe("ElectronJsRuntimeDebugger"); + expect(electronJsRuntimeDebuggerBlueprint.namespace).toBe("ElectronJsRuntimeDebugger"); + expect(electronJsRuntimeDebuggerBlueprint.getURN("electron-cdp-9222")).toBe( + "ElectronJsRuntimeDebugger:electron-cdp-9222" + ); + const ref = electronJsRuntimeDebuggerRef(electronDevice); + expect(ref.urn).toBe("ElectronJsRuntimeDebugger:electron-cdp-19222"); + expect(ref.options.device).toEqual(electronDevice); + }); + + it("declares ElectronCdp as its dep so the registry resolves the page session first", () => { + const deps = electronJsRuntimeDebuggerBlueprint.getDependencies!("electron-cdp-19222"); + expect(deps).toEqual({ electron: "ElectronCdp:electron-cdp-19222" }); + }); + + it("factory rejects without options.device", async () => { + await expect( + electronJsRuntimeDebuggerBlueprint.factory( + { electron: makeFakeElectronCdpApi().api }, + "electron-cdp-19222", + undefined + ) + ).rejects.toThrow(/requires a resolved DeviceInfo/); + }); + + it("factory rejects when options.device.id disagrees with the payload", async () => { + await expect( + electronJsRuntimeDebuggerBlueprint.factory( + { electron: makeFakeElectronCdpApi().api }, + "electron-cdp-19222", + { device: resolveDevice("electron-cdp-9999") } + ) + ).rejects.toThrow(/payload .* does not match/); + }); + + it("factory: produces a JsRuntimeDebuggerApi-shaped object and subscribes to consoleAPICalled", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + try { + expect(instance.api.port).toBe(19222); + expect(instance.api.projectRoot).toBe(""); + expect(instance.api.logicalDeviceId).toBe("electron-cdp-19222"); + expect(instance.api.isNewDebugger).toBe(true); + expect(instance.api.cdp).toBe(fake.api.cdp); + // sourceResolver / sourceMaps stubs exist (only used by locked-out + // inspect-element, but the type contract must hold). + expect(typeof instance.api.sourceResolver.symbolicate).toBe("function"); + expect(typeof instance.api.sourceMaps.waitForPending).toBe("function"); + await expect(instance.api.sourceMaps.waitForPending()).resolves.toBeUndefined(); + + // Console events from the CDP feed through to the api's consoleEvents. + const received: unknown[] = []; + instance.api.consoleEvents.on("log", (entry) => received.push(entry)); + fake.events.emit("consoleAPICalled", { + type: "log", + args: [{ type: "string", value: "hello" }], + timestamp: Date.now(), + }); + expect(received).toHaveLength(1); + expect((received[0] as { message: string }).message).toBe("hello"); + + // Binding is registered best-effort so future tools using + // evaluateWithBinding don't need their own setup. + expect(fake.addBindingSpy).toHaveBeenCalledWith("__argent_callback"); + } finally { + await instance.dispose(); + } + }); + + it("dispose unsubscribes from the underlying CDP — events do NOT keep firing", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + const received: unknown[] = []; + instance.api.consoleEvents.on("log", (entry) => received.push(entry)); + await instance.dispose(); + fake.events.emit("consoleAPICalled", { + type: "log", + args: [{ type: "string", value: "after-dispose" }], + timestamp: Date.now(), + }); + expect(received).toHaveLength(0); + }); + + it("dispose does NOT disconnect the underlying CDP — that belongs to ElectronCdp", async () => { + const fake = makeFakeElectronCdpApi(); + // Track whether anything calls disconnect on the cdp. + const disconnect = vi.fn(); + (fake.api.cdp as unknown as { disconnect: typeof disconnect }).disconnect = disconnect; + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + await instance.dispose(); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it("cdp.disconnected → events.terminated propagation, with the original error preserved", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + try { + const terminated: Array = []; + instance.events.on("terminated", (err) => terminated.push(err)); + const cause = new Error("websocket closed by peer"); + fake.events.emit("disconnected", cause); + expect(terminated).toHaveLength(1); + expect(terminated[0]).toBe(cause); + } finally { + await instance.dispose(); + } + }); + + it("cdp.disconnected with no error still emits a terminated event with a synthetic Error", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + try { + const terminated: Array = []; + instance.events.on("terminated", (err) => terminated.push(err)); + fake.events.emit("disconnected", undefined); + expect(terminated).toHaveLength(1); + expect(terminated[0]).toBeInstanceOf(Error); + expect((terminated[0] as Error).message).toMatch(/Electron CDP disconnected/); + } finally { + await instance.dispose(); + } + }); + + it("dispose detaches the disconnected listener — no terminated emission after dispose", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + const terminated: unknown[] = []; + instance.events.on("terminated", (err) => terminated.push(err)); + await instance.dispose(); + fake.events.emit("disconnected", new Error("late")); + expect(terminated).toHaveLength(0); + }); + + it("a non-finite consoleAPICalled.timestamp is coerced — entry is captured, not silently dropped", async () => { + const fake = makeFakeElectronCdpApi(); + const instance = await electronJsRuntimeDebuggerBlueprint.factory( + { electron: fake.api }, + "electron-cdp-19222", + { device: electronDevice } + ); + try { + const received: Array<{ message: string; timestamp: number }> = []; + instance.api.consoleEvents.on("log", (entry) => + received.push({ message: entry.message, timestamp: entry.timestamp }) + ); + const before = Date.now(); + fake.events.emit("consoleAPICalled", { + type: "log", + args: [{ type: "string", value: "nan-test" }], + timestamp: Number.NaN, + }); + const after = Date.now(); + expect(received).toHaveLength(1); + expect(received[0].message).toBe("nan-test"); + // Coerced to Date.now() — must be finite and within the call window. + expect(Number.isFinite(received[0].timestamp)).toBe(true); + expect(received[0].timestamp).toBeGreaterThanOrEqual(before); + expect(received[0].timestamp).toBeLessThanOrEqual(after); + } finally { + await instance.dispose(); + } + }); +}); diff --git a/packages/tool-server/test/electron-scroll.test.ts b/packages/tool-server/test/electron-scroll.test.ts new file mode 100644 index 00000000..8dc7415d --- /dev/null +++ b/packages/tool-server/test/electron-scroll.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import { gestureScrollTool } from "../src/tools/gesture-scroll"; +import { gestureSwipeTool } from "../src/tools/gesture-swipe"; +import { assertSupported, UnsupportedOperationError } from "../src/utils/capability"; +import { resolveDevice } from "../src/utils/device-info"; + +// The scroll/swipe split: a desktop renderer scrolls with wheel events +// (gesture-scroll, electron-only) while touch platforms scroll with a drag +// gesture (gesture-swipe, ios/android-only). These tests pin both the wheel +// dispatch math and the capability fence between the two tools. + +const electronDevice = resolveDevice("electron-cdp-19222"); +const iosDevice = resolveDevice("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"); +const androidDevice = resolveDevice("emulator-5554"); + +function fakeElectronApi() { + return { + getViewport: () => ({ width: 800, height: 600, devicePixelRatio: 2 }), + server: { sendWheel: vi.fn().mockResolvedValue(undefined) }, + }; +} + +describe("gesture-scroll", () => { + it("dispatches chunked wheel deltas at the anchor point, totalling the requested fraction", async () => { + const api = fakeElectronApi(); + const result = await gestureScrollTool.execute( + { electron: api } as never, + { udid: "electron-cdp-19222", x: 0.5, y: 0.65, deltaY: 0.25, durationMs: 64 } as never + ); + expect(result.scrolled).toBe(true); + const calls = api.server.sendWheel.mock.calls; + expect(calls.length).toBeGreaterThan(1); + expect(calls[0]![0]).toEqual({ x: 0.5, y: 0.65 }); + const totalDx = calls.reduce((sum, c) => sum + (c[1] as number), 0); + const totalDy = calls.reduce((sum, c) => sum + (c[2] as number), 0); + expect(totalDx).toBeCloseTo(0, 5); + expect(totalDy).toBeCloseTo(0.25 * 600, 5); + }); + + it("supports horizontal and negative (scroll-back-up) deltas", async () => { + const api = fakeElectronApi(); + await gestureScrollTool.execute( + { electron: api } as never, + { + udid: "electron-cdp-19222", + x: 0.5, + y: 0.5, + deltaX: 0.1, + deltaY: -0.5, + durationMs: 32, + } as never + ); + const calls = api.server.sendWheel.mock.calls; + const totalDx = calls.reduce((sum, c) => sum + (c[1] as number), 0); + const totalDy = calls.reduce((sum, c) => sum + (c[2] as number), 0); + expect(totalDx).toBeCloseTo(0.1 * 800, 5); + expect(totalDy).toBeCloseTo(-0.5 * 600, 5); + }); + + it("schema rejects a scroll with no delta", () => { + const parsed = gestureScrollTool.zodSchema!.safeParse({ + udid: "electron-cdp-19222", + x: 0.5, + y: 0.5, + }); + expect(parsed.success).toBe(false); + }); + + it("is electron-only: capability gate rejects iOS and Android targets", () => { + expect(() => + assertSupported("gesture-scroll", gestureScrollTool.capability!, electronDevice) + ).not.toThrow(); + expect(() => + assertSupported("gesture-scroll", gestureScrollTool.capability!, iosDevice) + ).toThrow(UnsupportedOperationError); + expect(() => + assertSupported("gesture-scroll", gestureScrollTool.capability!, androidDevice) + ).toThrow(UnsupportedOperationError); + }); +}); + +describe("gesture-swipe electron lockout", () => { + it("no longer declares electron support — the gate names the tool clearly", () => { + expect(gestureSwipeTool.capability).not.toHaveProperty("electron"); + expect(() => + assertSupported("gesture-swipe", gestureSwipeTool.capability!, electronDevice) + ).toThrow(/gesture-swipe.*not supported on electron/); + }); +}); diff --git a/packages/tool-server/test/electron-server-input.test.ts b/packages/tool-server/test/electron-server-input.test.ts new file mode 100644 index 00000000..68375f50 --- /dev/null +++ b/packages/tool-server/test/electron-server-input.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from "vitest"; +import { + sendButton, + sendKey, + sendRotate, + sendTouch, + sendWheel, +} from "../src/electron-server/input"; +import type { CDPClient } from "../src/utils/debugger/cdp-client"; + +function stubCdp() { + const send = vi.fn().mockResolvedValue({}); + return { send } as unknown as CDPClient; +} + +const viewport = { width: 800, height: 600, devicePixelRatio: 1 }; + +describe("electron-server/input", () => { + describe("sendTouch", () => { + it("converts a normalized Down to mousePressed at CSS pixels", async () => { + const cdp = stubCdp(); + await sendTouch(cdp, viewport, "Down", { x: 0.5, y: 0.25 }); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 400, + y: 150, + button: "left", + buttons: 1, + clickCount: 1, + }); + }); + + it("converts an Up to mouseReleased", async () => { + const cdp = stubCdp(); + await sendTouch(cdp, viewport, "Up", { x: 0, y: 1 }); + const send = (cdp as unknown as { send: ReturnType }).send; + const call = send.mock.calls[0]!; + expect(call[0]).toBe("Input.dispatchMouseEvent"); + expect((call[1] as Record).type).toBe("mouseReleased"); + expect((call[1] as Record).x).toBe(0); + expect((call[1] as Record).y).toBe(600); + }); + + it("Move uses mouseMoved with button none / buttons 0", async () => { + const cdp = stubCdp(); + await sendTouch(cdp, viewport, "Move", { x: 0.5, y: 0.5 }); + const send = (cdp as unknown as { send: ReturnType }).send; + const payload = send.mock.calls[0]?.[1] as Record; + expect(payload.type).toBe("mouseMoved"); + expect(payload.button).toBe("none"); + expect(payload.buttons).toBe(0); + // mouseMoved must NOT carry clickCount — CDP ignores it but the + // sim-server-style payload should remain minimal. + expect("clickCount" in payload).toBe(false); + }); + + it("multi-touch uses Input.dispatchTouchEvent", async () => { + const cdp = stubCdp(); + await sendTouch(cdp, viewport, "Down", { x: 0.2, y: 0.3 }, { x: 0.7, y: 0.8 }); + const send = (cdp as unknown as { send: ReturnType }).send; + const call = send.mock.calls[0]!; + expect(call[0]).toBe("Input.dispatchTouchEvent"); + const payload = call[1] as { type: string; touchPoints: Array<{ x: number; y: number }> }; + expect(payload.type).toBe("touchStart"); + expect(payload.touchPoints).toHaveLength(2); + expect(payload.touchPoints[0]?.x).toBe(160); + expect(payload.touchPoints[1]?.x).toBe(560); + }); + + it("throws for non-finite coordinates", async () => { + const cdp = stubCdp(); + await expect(sendTouch(cdp, viewport, "Down", { x: Number.NaN, y: 0.5 })).rejects.toThrow( + /non-finite/i + ); + }); + + it("clamps out-of-range normalized coords", async () => { + const cdp = stubCdp(); + await sendTouch(cdp, viewport, "Down", { x: 5, y: -1 }); + const send = (cdp as unknown as { send: ReturnType }).send; + const payload = send.mock.calls[0]?.[1] as { x: number; y: number }; + expect(payload.x).toBe(viewport.width); + expect(payload.y).toBe(0); + }); + }); + + describe("sendWheel", () => { + it("forwards dx/dy as mouseWheel deltas", async () => { + const cdp = stubCdp(); + await sendWheel(cdp, viewport, { x: 0.5, y: 0.5 }, 10, -25); + const send = (cdp as unknown as { send: ReturnType }).send; + const payload = send.mock.calls[0]?.[1] as Record; + expect(payload.type).toBe("mouseWheel"); + expect(payload.deltaX).toBe(10); + expect(payload.deltaY).toBe(-25); + }); + + it("rejects non-finite deltas", async () => { + const cdp = stubCdp(); + await expect(sendWheel(cdp, viewport, { x: 0, y: 0 }, Infinity, 0)).rejects.toThrow( + /non-finite/i + ); + }); + + it("noops a zero-delta wheel without sending", async () => { + const cdp = stubCdp(); + await sendWheel(cdp, viewport, { x: 0.5, y: 0.5 }, 0, 0); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).not.toHaveBeenCalled(); + }); + }); + + describe("sendKey", () => { + it("threads key/code/text/vk into Input.dispatchKeyEvent", async () => { + const cdp = stubCdp(); + await sendKey(cdp, "Down", { key: "a", codeName: "KeyA", text: "a", code: 65 }); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Input.dispatchKeyEvent", { + type: "keyDown", + key: "a", + code: "KeyA", + text: "a", + windowsVirtualKeyCode: 65, + }); + }); + }); + + describe("sendButton", () => { + it("Back maps to Alt+ArrowLeft", async () => { + const cdp = stubCdp(); + await sendButton(cdp, "Back", "Down"); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send.mock.calls.length).toBe(2); + expect((send.mock.calls[0]?.[1] as Record).key).toBe("Alt"); + expect((send.mock.calls[1]?.[1] as Record).key).toBe("ArrowLeft"); + }); + + it("Home throws — no browser equivalent", async () => { + const cdp = stubCdp(); + await expect(sendButton(cdp, "Home", "Down")).rejects.toThrow(/does not support/); + }); + + it("Power / Volume / AppSwitch all throw", async () => { + const cdp = stubCdp(); + for (const btn of ["Power", "VolumeUp", "VolumeDown", "AppSwitch", "ActionButton"] as const) { + await expect(sendButton(cdp, btn, "Down")).rejects.toThrow(/does not support/); + } + }); + }); + + describe("sendRotate", () => { + it("sets device-metrics override with the expected angle", async () => { + const cdp = stubCdp(); + await sendRotate(cdp, viewport, "LandscapeRight"); + const send = (cdp as unknown as { send: ReturnType }).send; + const payload = send.mock.calls[0]?.[1] as { + screenOrientation: { angle: number; type: string }; + }; + expect(payload.screenOrientation.angle).toBe(90); + expect(payload.screenOrientation.type).toBe("landscapePrimary"); + }); + }); +}); diff --git a/packages/tool-server/test/electron-server-navigation.test.ts b/packages/tool-server/test/electron-server-navigation.test.ts new file mode 100644 index 00000000..a75efd0f --- /dev/null +++ b/packages/tool-server/test/electron-server-navigation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { goBack, goForward, navigate, reload } from "../src/electron-server/navigation"; +import type { CDPClient } from "../src/utils/debugger/cdp-client"; + +function stubCdp(history?: { + currentIndex: number; + entries: Array<{ id: number; url: string; title: string }>; +}) { + const send = vi.fn().mockImplementation((method: string) => { + if (method === "Page.getNavigationHistory" && history) return Promise.resolve(history); + return Promise.resolve({}); + }); + return { send } as unknown as CDPClient; +} + +describe("electron-server/navigation", () => { + it("navigate: forwards to Page.navigate", async () => { + const cdp = stubCdp(); + await navigate(cdp, "https://example.com"); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Page.navigate", { url: "https://example.com" }); + }); + + it("reload: forwards ignoreCache flag", async () => { + const cdp = stubCdp(); + await reload(cdp, true); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Page.reload", { ignoreCache: true }); + }); + + it("goBack: at the oldest entry returns false without navigating", async () => { + const cdp = stubCdp({ + currentIndex: 0, + entries: [{ id: 1, url: "about:blank", title: "" }], + }); + expect(await goBack(cdp)).toBe(false); + const send = (cdp as unknown as { send: ReturnType }).send; + // Only the history-query was called — no navigateToHistoryEntry. + const navs = send.mock.calls.filter((c) => c[0] === "Page.navigateToHistoryEntry"); + expect(navs.length).toBe(0); + }); + + it("goBack: walks one entry and navigates to its id", async () => { + const cdp = stubCdp({ + currentIndex: 2, + entries: [ + { id: 1, url: "/a", title: "" }, + { id: 2, url: "/b", title: "" }, + { id: 3, url: "/c", title: "" }, + ], + }); + expect(await goBack(cdp)).toBe(true); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Page.navigateToHistoryEntry", { entryId: 2 }); + }); + + it("goForward: at the newest entry returns false", async () => { + const cdp = stubCdp({ + currentIndex: 2, + entries: [ + { id: 1, url: "/a", title: "" }, + { id: 2, url: "/b", title: "" }, + { id: 3, url: "/c", title: "" }, + ], + }); + expect(await goForward(cdp)).toBe(false); + }); +}); diff --git a/packages/tool-server/test/electron-server-screencast.test.ts b/packages/tool-server/test/electron-server-screencast.test.ts new file mode 100644 index 00000000..cb01ad4b --- /dev/null +++ b/packages/tool-server/test/electron-server-screencast.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { TypedEventEmitter } from "@argent/registry"; +import { FpsTracker } from "../src/electron-server/fps"; +import { ScreencastManager } from "../src/electron-server/screencast"; +import type { CDPClient } from "../src/utils/debugger/cdp-client"; +import type { ScreencastFrame, ServerEvents } from "../src/electron-server/types"; + +interface FakeCdp { + send: ReturnType; + events: TypedEventEmitter<{ event: (method: string, params: Record) => void }>; + emitFrame(frame: Partial & { sessionId: number; data: string }): void; +} + +function makeFakeCdp(): FakeCdp { + const send = vi.fn().mockResolvedValue({}); + const events = new TypedEventEmitter<{ + event: (method: string, params: Record) => void; + }>(); + return { + send, + events, + emitFrame(frame) { + events.emit("event", "Page.screencastFrame", { + sessionId: frame.sessionId, + data: frame.data, + metadata: frame.metadata ?? { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: 800, + deviceHeight: 600, + scrollOffsetX: 0, + scrollOffsetY: 0, + }, + }); + }, + }; +} + +describe("electron-server/screencast", () => { + it("starts CDP screencast on the first subscriber and stops on the last", async () => { + const cdp = makeFakeCdp(); + const events = new TypedEventEmitter(); + const fps = new FpsTracker(events); + const mgr = new ScreencastManager(cdp as unknown as CDPClient, events, fps); + + const s1 = await mgr.start({ format: "jpeg", quality: 60 }); + expect(cdp.send).toHaveBeenCalledWith("Page.startScreencast", { + format: "jpeg", + quality: 60, + everyNthFrame: 1, + }); + + // Second subscriber shares the session — no second startScreencast. + cdp.send.mockClear(); + const s2 = await mgr.start({ format: "jpeg", quality: 60 }); + expect(cdp.send).not.toHaveBeenCalled(); + + // First stop doesn't tear down — second subscriber still active. + await s1.stop(); + expect(cdp.send).not.toHaveBeenCalled(); + + // Second stop tears down. + await s2.stop(); + expect(cdp.send).toHaveBeenCalledWith("Page.stopScreencast"); + }); + + it("emits a 'frame' event and acks every frame so CDP keeps streaming", async () => { + const cdp = makeFakeCdp(); + const events = new TypedEventEmitter(); + const fps = new FpsTracker(events); + const mgr = new ScreencastManager(cdp as unknown as CDPClient, events, fps); + + const frames: ScreencastFrame[] = []; + events.on("frame", (f) => frames.push(f)); + await mgr.start(); + + cdp.emitFrame({ sessionId: 1, data: "AAA" }); + cdp.emitFrame({ sessionId: 2, data: "BBB" }); + + expect(frames).toHaveLength(2); + expect(frames[0]?.data).toBe("AAA"); + expect(frames[1]?.sessionId).toBe(2); + + // Acks: we expect at least one ack per sessionId received. + const ackCalls = cdp.send.mock.calls.filter((c) => c[0] === "Page.screencastFrameAck"); + expect(ackCalls.length).toBe(2); + expect((ackCalls[0]?.[1] as { sessionId: number }).sessionId).toBe(1); + expect((ackCalls[1]?.[1] as { sessionId: number }).sessionId).toBe(2); + + expect(mgr.getLastFrame()?.data).toBe("BBB"); + }); + + it("idempotent stop(): calling twice is safe", async () => { + const cdp = makeFakeCdp(); + const events = new TypedEventEmitter(); + const fps = new FpsTracker(events); + const mgr = new ScreencastManager(cdp as unknown as CDPClient, events, fps); + const s = await mgr.start(); + await s.stop(); + await s.stop(); // no-op the second time + const stops = cdp.send.mock.calls.filter((c) => c[0] === "Page.stopScreencast"); + expect(stops.length).toBe(1); + }); +}); + +describe("electron-server/fps", () => { + it("emits fpsReport once per second when enabled", async () => { + vi.useFakeTimers(); + try { + const events = new TypedEventEmitter(); + const reports: Array<{ fps: number }> = []; + events.on("fpsReport", (r) => reports.push(r)); + const tracker = new FpsTracker(events); + tracker.setEnabled(true); + + tracker.recordFrame(); + tracker.recordFrame(); + tracker.recordFrame(); + vi.advanceTimersByTime(1000); + expect(reports[0]).toEqual({ fps: 3, windowMs: 1000 }); + + // Second window resets the counter. + tracker.recordFrame(); + vi.advanceTimersByTime(1000); + expect(reports[1]).toEqual({ fps: 1, windowMs: 1000 }); + + tracker.setEnabled(false); + tracker.recordFrame(); + vi.advanceTimersByTime(1000); + // No new report after disabling. + expect(reports).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it("setEnabled is idempotent", () => { + const events = new TypedEventEmitter(); + const tracker = new FpsTracker(events); + tracker.setEnabled(true); + tracker.setEnabled(true); // should not double-arm the interval + tracker.dispose(); + }); +}); diff --git a/packages/tool-server/test/electron-server-screenshot.test.ts b/packages/tool-server/test/electron-server-screenshot.test.ts new file mode 100644 index 00000000..3c0e3436 --- /dev/null +++ b/packages/tool-server/test/electron-server-screenshot.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { __resetSharpCacheForTests, captureScreenshot } from "../src/electron-server/screenshot"; +import type { CDPClient } from "../src/utils/debugger/cdp-client"; + +// 1×1 transparent PNG — small enough to embed inline, valid IHDR so the +// internal readPngSize check succeeds. +const ONE_PX_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII="; + +function stubCdp(captureBase64 = ONE_PX_PNG_BASE64) { + const send = vi.fn().mockResolvedValue({ data: captureBase64 }); + return { send } as unknown as CDPClient; +} + +const filesToCleanup: string[] = []; + +beforeEach(() => { + __resetSharpCacheForTests(); +}); + +afterEach(() => { + for (const p of filesToCleanup.splice(0)) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } +}); + +describe("electron-server/screenshot", () => { + it("writes a PNG and returns file:// url + absolute path", async () => { + const cdp = stubCdp(); + const out = await captureScreenshot({ cdp, deviceId: "electron-cdp-12345" }); + filesToCleanup.push(out.path); + expect(out.path).toMatch(/argent-electron-media/); + expect(out.path).toMatch(/argent-screenshot-electron-cdp-12345-/); + expect(out.url).toBe(`file://${out.path}`); + expect(fs.existsSync(out.path)).toBe(true); + }); + + it("calls Page.captureScreenshot with format png + no captureBeyondViewport", async () => { + const cdp = stubCdp(); + await captureScreenshot({ cdp, deviceId: "test" }); + const send = (cdp as unknown as { send: ReturnType }).send; + expect(send).toHaveBeenCalledWith("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + }); + }); + + it("uses the provided id in the filename", async () => { + const cdp = stubCdp(); + const out = await captureScreenshot({ cdp, deviceId: "test" }, { id: "demo-123" }); + filesToCleanup.push(out.path); + expect(out.path).toMatch(/demo-123\.png$/); + }); + + it("sanitizes deviceId for use in the filename", async () => { + const cdp = stubCdp(); + // Slashes and colons would break the file path; the sanitizer replaces + // them with underscores so a malformed id can't escape the media dir. + const out = await captureScreenshot({ cdp, deviceId: "../../etc/passwd:bad" }, { id: "x" }); + filesToCleanup.push(out.path); + expect(out.path).toMatch(/argent-screenshot-______etc_passwd_bad-x\.png/); + }); + + it("emits a one-time stderr warning when sharp is missing and downscale was requested", async () => { + // Force the dynamic `require("sharp")` to throw — independent of whether + // sharp is actually installed in the test environment — by stubbing + // Module._resolveFilename to fail for "sharp" specifically. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Module = require("node:module") as { + _resolveFilename: (...args: unknown[]) => string; + }; + const original = Module._resolveFilename.bind(Module); + Module._resolveFilename = ((request: string, ...rest: unknown[]) => { + if (request === "sharp") throw new Error("forced sharp-missing for test"); + return original(request, ...rest); + }) as typeof Module._resolveFilename; + + const cdp = stubCdp(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const out = await captureScreenshot({ cdp, deviceId: "test" }, { scale: 0.5 }); + filesToCleanup.push(out.path); + const warnings = stderr.mock.calls + .map((args) => String(args[0])) + .filter((s) => s.includes("[electron-screenshot]")); + expect(warnings.length).toBeGreaterThanOrEqual(1); + expect(warnings[0]).toMatch(/sharp is not installed/); + } finally { + stderr.mockRestore(); + Module._resolveFilename = original as typeof Module._resolveFilename; + } + }); + + it("does not emit the sharp warning when no post-processing was requested", async () => { + const cdp = stubCdp(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const out = await captureScreenshot({ cdp, deviceId: "test" }); + filesToCleanup.push(out.path); + const warnings = stderr.mock.calls + .map((args) => String(args[0])) + .filter((s) => s.includes("[electron-screenshot]")); + expect(warnings.length).toBe(0); + } finally { + stderr.mockRestore(); + } + }); + + it("throws a clear error when CDP returns no data", async () => { + const send = vi.fn().mockResolvedValue({}); + const cdp = { send } as unknown as CDPClient; + await expect(captureScreenshot({ cdp, deviceId: "test" })).rejects.toThrow(/returned no data/); + }); +}); diff --git a/packages/tool-server/test/ios-only-blueprint-gate.test.ts b/packages/tool-server/test/ios-only-blueprint-gate.test.ts index befb32cd..10f27785 100644 --- a/packages/tool-server/test/ios-only-blueprint-gate.test.ts +++ b/packages/tool-server/test/ios-only-blueprint-gate.test.ts @@ -35,7 +35,7 @@ describe("iOS-only blueprints reject Android targets up-front", () => { it("native-devtools blueprint rejects an Android device with a targeted error", async () => { const device = androidDevice("emulator-5554"); await expect(nativeDevtoolsBlueprint.factory({}, device, { device })).rejects.toThrow( - /NativeDevtools is iOS-only.*Android/ + /NativeDevtools is iOS-only.*classifies as android/ ); }); @@ -86,7 +86,7 @@ describe("iOS-only blueprints reject Android targets up-front", () => { it("ax-service blueprint rejects an Android device with a targeted error", async () => { const device = androidDevice("emulator-5554"); await expect(axServiceBlueprint.factory({}, device, { device })).rejects.toThrow( - /AXService is iOS-only.*Android.*uiautomator/ + /AXService is iOS-only.*classifies as android.*uiautomator/ ); }); diff --git a/packages/tool-server/test/list-devices.test.ts b/packages/tool-server/test/list-devices.test.ts index 66a835b1..e521c59d 100644 --- a/packages/tool-server/test/list-devices.test.ts +++ b/packages/tool-server/test/list-devices.test.ts @@ -30,6 +30,17 @@ vi.mock("../src/utils/android-binary", () => ({ __resetAndroidBinaryCacheForTesting: () => {}, })); +// Electron discovery probes real TCP ports (9222 plus any persisted by a +// previous tool-server on this machine). A developer actually running an +// Electron app would leak it into this test's device list — stub discovery +// so the result only contains what the simctl / adb mocks define. +vi.mock("../src/utils/electron-discovery", async () => { + const actual = await vi.importActual( + "../src/utils/electron-discovery" + ); + return { ...actual, discoverElectronDevices: vi.fn(async () => []) }; +}); + import { listDevicesTool } from "../src/tools/devices/list-devices"; function simctlJson(): string {