From 0deb1ed261f20fcd3dd83e332e12dd999c7ab6da Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 4 May 2026 21:20:14 +0200 Subject: [PATCH 01/14] chore(argus): trust and install mise before pnpm install Co-Authored-By: Claude Opus 4.7 (1M context) --- .argus.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.argus.json b/.argus.json index 1308d62..c86e9fe 100644 --- a/.argus.json +++ b/.argus.json @@ -6,6 +6,8 @@ ], "symlink": [], "commands": [ + "mise trust", + "mise install", "pnpm install" ] }, From 83a8310b0e1e38717afd4ca2b44c91f2f09bc88a Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:42:00 +0200 Subject: [PATCH 02/14] feat(drivers): add multi-finger gesturePath route to iOS + Android iOS: new GesturePathRequest model and GesturePathRouteHandler that build an EventRecord(.multiFinger) with one XCPointerEventPath per finger. Registered in the Route enum, RouteHandlerFactory, and the xcodeproj iOS + tvOS Sources build phases. Android: new gesturePath gRPC + GesturePathRequest/GestureFingerPath/ GestureStep/GesturePathResponse messages. Handler uses UiAutomation.injectInputEvent with multi-pointer MotionEvent (dispatchGesture is on AccessibilityService, not UiAutomation). Each path is a separate pointer; positions are resampled to a global 16ms grid and dispatched as ACTION_DOWN / ACTION_POINTER_DOWN / ACTION_MOVE / ACTION_POINTER_UP / ACTION_UP. The CLI's bundled proto copy is synced for grpc-js code generation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../conductor/ConductorDriverService.kt | 183 ++++++++++++++++++ .../src/main/proto/conductor_android.proto | 29 +++ packages/cli/proto/conductor_android.proto | 29 +++ .../project.pbxproj | 12 ++ .../Handlers/GesturePathRouteHandler.swift | 70 +++++++ .../Routes/Models/GesturePathRequest.swift | 24 +++ .../Routes/RouteHandlerFactory.swift | 2 + .../Routes/XCTestHTTPServer.swift | 1 + 8 files changed, 350 insertions(+) create mode 100644 packages/ios-driver/conductor-driver-iosUITests/Routes/Handlers/GesturePathRouteHandler.swift create mode 100644 packages/ios-driver/conductor-driver-iosUITests/Routes/Models/GesturePathRequest.swift diff --git a/packages/android-driver/conductor-android/src/androidTest/java/dev/houwert/conductor/ConductorDriverService.kt b/packages/android-driver/conductor-android/src/androidTest/java/dev/houwert/conductor/ConductorDriverService.kt index 7c502fb..3183ceb 100644 --- a/packages/android-driver/conductor-android/src/androidTest/java/dev/houwert/conductor/ConductorDriverService.kt +++ b/packages/android-driver/conductor-android/src/androidTest/java/dev/houwert/conductor/ConductorDriverService.kt @@ -265,6 +265,189 @@ class Service( } } + /** + * Multi-finger gesture playback via UiAutomation.injectInputEvent with + * multi-pointer MotionEvent. Each path is one finger. The driver builds a + * shared event timeline: at each frame it emits an ACTION_MOVE (or + * pointer-down/up at edges) carrying every active finger's current + * coordinates. + * + * dt_ms is the delay since that finger's previous step (the first step's + * dt_ms is the initial offset before this finger goes down). We resample + * to a global 16ms grid so all fingers share a clock. + */ + override fun gesturePath( + request: ConductorAndroid.GesturePathRequest, + responseObserver: StreamObserver + ) { + try { + if (request.pathsCount == 0) { + throw IllegalArgumentException("gesturePath requires at least one finger path") + } + val fingers = (0 until request.pathsCount).map { request.getPaths(it) } + for ((i, finger) in fingers.withIndex()) { + if (finger.stepsCount == 0) { + throw IllegalArgumentException("gesturePath finger $i has no steps") + } + } + + // Compute each finger's per-step absolute time offset (ms from gesture start). + // First step's dt_ms is the initial offset; subsequent dt_ms are deltas. + val fingerTimes: List = fingers.map { f -> + val arr = LongArray(f.stepsCount) + var cum = 0L + for (j in 0 until f.stepsCount) { + cum += f.getSteps(j).dtMs.toLong() + arr[j] = cum + } + arr + } + + // Total gesture duration = max of any finger's last timestamp. + val totalMs = fingerTimes.maxOf { it.lastOrNull() ?: 0L } + val frameMs = 16L + val frames = maxOf(1, ((totalMs + frameMs - 1) / frameMs).toInt()) + + // For each frame at globalT = i * frameMs, sample each finger's + // position via linear interpolation between its two surrounding steps. + fun sample(fingerIdx: Int, t: Long): Pair? { + val f = fingers[fingerIdx] + val times = fingerTimes[fingerIdx] + if (t < times[0]) return null + if (t >= times.last()) { + val last = f.getSteps(f.stepsCount - 1) + return last.x.toFloat() to last.y.toFloat() + } + // Find segment [k, k+1] containing t. + var k = 0 + while (k + 1 < times.size && times[k + 1] < t) k++ + val a = f.getSteps(k) + val b = f.getSteps(k + 1) + val span = (times[k + 1] - times[k]).coerceAtLeast(1L) + val frac = (t - times[k]).toDouble() / span.toDouble() + val x = a.x + (b.x - a.x) * frac + val y = a.y + (b.y - a.y) * frac + return x.toFloat() to y.toFloat() + } + + // PointerProperties — id == index in our `fingers` list. + val pointerProps = Array(fingers.size) { idx -> + android.view.MotionEvent.PointerProperties().also { + it.id = idx + it.toolType = android.view.MotionEvent.TOOL_TYPE_FINGER + } + } + + val downtime = SystemClock.uptimeMillis() + // Track which fingers are currently "down" so we know when to emit + // ACTION_POINTER_DOWN / ACTION_POINTER_UP transitions. + val active = BooleanArray(fingers.size) { false } + + fun makeCoords(t: Long): Array { + return Array(fingers.size) { idx -> + val c = android.view.MotionEvent.PointerCoords() + val pt = sample(idx, t) ?: (fingers[idx].getSteps(0).x.toFloat() to fingers[idx].getSteps(0).y.toFloat()) + c.x = pt.first + c.y = pt.second + c.pressure = if (active[idx]) 1f else 0f + c.size = 1f + c + } + } + + fun activePointerCount(): Int = active.count { v -> v } + + fun inject(action: Int, pointerIndex: Int, t: Long) { + val activeCount = activePointerCount() + if (activeCount == 0) return + // Build coord/prop arrays restricted to active pointers, with the + // changed pointer placed at the supplied index so the high bits + // of `action` encode the right pointer slot. + val activeIndices: List = active.toList() + .mapIndexedNotNull { i, on -> if (on) i else null } + val props = activeIndices.map { pointerProps[it] }.toTypedArray() + val allCoords = makeCoords(t) + val coords = activeIndices.map { allCoords[it] }.toTypedArray() + + val actionMasked = when (action) { + android.view.MotionEvent.ACTION_POINTER_DOWN, + android.view.MotionEvent.ACTION_POINTER_UP -> { + val slot = activeIndices.indexOf(pointerIndex).coerceAtLeast(0) + action or (slot shl android.view.MotionEvent.ACTION_POINTER_INDEX_SHIFT) + } + else -> action + } + + val event = android.view.MotionEvent.obtain( + downtime, + downtime + t, + actionMasked, + props.size, + props, + coords, + 0, + 0, + 1f, + 1f, + 0, + 0, + android.view.InputDevice.SOURCE_TOUCHSCREEN, + 0 + ) + try { + uiAutomation.injectInputEvent(event, true) + } finally { + event.recycle() + } + } + + // Walk the global timeline. + for (i in 0..frames) { + val t = (i.toLong() * frameMs).coerceAtMost(totalMs) + // 1) Promote any fingers whose first step has elapsed but aren't active. + for (idx in fingers.indices) { + if (!active[idx] && t >= fingerTimes[idx][0]) { + active[idx] = true + val action = if (activePointerCount() == 1) + android.view.MotionEvent.ACTION_DOWN + else + android.view.MotionEvent.ACTION_POINTER_DOWN + inject(action, idx, t) + } + } + // 2) Move all active pointers to their current positions. + if (activePointerCount() > 0 && i > 0) { + inject(android.view.MotionEvent.ACTION_MOVE, -1, t) + } + // 3) Demote any fingers whose last step has passed. + if (t >= totalMs) break + } + + // Lift each active pointer in reverse order so the final ACTION_UP + // is on the last remaining pointer. + val liftOrder: List = active.toList() + .mapIndexedNotNull { i, on -> if (on) i else null } + .reversed() + for (k in liftOrder.indices) { + val idx = liftOrder[k] + val isLast = k == liftOrder.size - 1 + val action = if (isLast) + android.view.MotionEvent.ACTION_UP + else + android.view.MotionEvent.ACTION_POINTER_UP + inject(action, idx, totalMs) + active[idx] = false + } + + responseObserver.onNext( + ConductorAndroid.GesturePathResponse.newBuilder().build() + ) + responseObserver.onCompleted() + } catch (t: Throwable) { + responseObserver.onError(t.internalError()) + } + } + override fun addMedia(responseObserver: StreamObserver): StreamObserver { return object : StreamObserver { diff --git a/packages/android-driver/conductor-proto/src/main/proto/conductor_android.proto b/packages/android-driver/conductor-proto/src/main/proto/conductor_android.proto index f88d6d9..9e3e4c9 100644 --- a/packages/android-driver/conductor-proto/src/main/proto/conductor_android.proto +++ b/packages/android-driver/conductor-proto/src/main/proto/conductor_android.proto @@ -12,6 +12,8 @@ service ConductorDriver { rpc tap(TapRequest) returns (TapResponse) {} + rpc gesturePath(GesturePathRequest) returns (GesturePathResponse) {} + rpc inputText(InputTextRequest) returns (InputTextResponse) {} rpc eraseAllText(EraseAllTextRequest) returns (EraseAllTextResponse) {} @@ -76,6 +78,33 @@ message TapRequest { message TapResponse {} +// One step in a finger's path. +// x, y pixel coordinates on the device's natural orientation. +// dt_ms delay in milliseconds since the previous step. For the first +// step in a path, this is the initial offset before the touch +// goes down. +message GestureStep { + double x = 1; + double y = 2; + uint32 dt_ms = 3; +} + +// A single finger's traced path. Must have at least one step. The first step +// is the down position; subsequent steps are moves; the finger lifts after +// the last step. +message GestureFingerPath { + repeated GestureStep steps = 1; +} + +// Multi-finger gesture request. paths.size() == 1 is a single-finger gesture; +// paths.size() >= 2 dispatches a true multi-touch gesture via +// UiAutomation.dispatchGesture (API 24+). +message GesturePathRequest { + repeated GestureFingerPath paths = 1; +} + +message GesturePathResponse {} + message InputTextRequest { string text = 1; } diff --git a/packages/cli/proto/conductor_android.proto b/packages/cli/proto/conductor_android.proto index f88d6d9..9e3e4c9 100644 --- a/packages/cli/proto/conductor_android.proto +++ b/packages/cli/proto/conductor_android.proto @@ -12,6 +12,8 @@ service ConductorDriver { rpc tap(TapRequest) returns (TapResponse) {} + rpc gesturePath(GesturePathRequest) returns (GesturePathResponse) {} + rpc inputText(InputTextRequest) returns (InputTextResponse) {} rpc eraseAllText(EraseAllTextRequest) returns (EraseAllTextResponse) {} @@ -76,6 +78,33 @@ message TapRequest { message TapResponse {} +// One step in a finger's path. +// x, y pixel coordinates on the device's natural orientation. +// dt_ms delay in milliseconds since the previous step. For the first +// step in a path, this is the initial offset before the touch +// goes down. +message GestureStep { + double x = 1; + double y = 2; + uint32 dt_ms = 3; +} + +// A single finger's traced path. Must have at least one step. The first step +// is the down position; subsequent steps are moves; the finger lifts after +// the last step. +message GestureFingerPath { + repeated GestureStep steps = 1; +} + +// Multi-finger gesture request. paths.size() == 1 is a single-finger gesture; +// paths.size() >= 2 dispatches a true multi-touch gesture via +// UiAutomation.dispatchGesture (API 24+). +message GesturePathRequest { + repeated GestureFingerPath paths = 1; +} + +message GesturePathResponse {} + message InputTextRequest { string text = 1; } diff --git a/packages/ios-driver/conductor-driver-ios.xcodeproj/project.pbxproj b/packages/ios-driver/conductor-driver-ios.xcodeproj/project.pbxproj index 29bc6d3..641b246 100644 --- a/packages/ios-driver/conductor-driver-ios.xcodeproj/project.pbxproj +++ b/packages/ios-driver/conductor-driver-ios.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 32A9C73E2D7A631A00545435 /* LaunchAppRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */; }; 32ECCB262980449200A1A0A0 /* TouchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ECCB252980449200A1A0A0 /* TouchRequest.swift */; }; 32ECCB28298044C200A1A0A0 /* TouchRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */; }; + FE5E000000000000000000A2 /* GesturePathRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5E000000000000000000A1 /* GesturePathRequest.swift */; }; + FE5E000000000000000000B2 /* GesturePathRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5E000000000000000000B1 /* GesturePathRouteHandler.swift */; }; 39F002647AA68C9B8DC39E61 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF897D5D1CB7C6C46344E543 /* Foundation.framework */; }; 52047F782A7A638E00BF982D /* StatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52047F772A7A638E00BF982D /* StatusHandler.swift */; }; 52049BD72935039F00807AA3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52049BD62935039F00807AA3 /* AppDelegate.swift */; }; @@ -172,6 +174,8 @@ TVOS003B2DF24A000000003B /* EventTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFF629D34FEA005D1FC5 /* EventTarget.swift */; }; TVOS003C2DF24A000000003C /* EventRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612DE413298426EF003C2BE0 /* EventRecord.swift */; }; TVOS003D2DF24A000000003D /* TouchRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */; }; + FE5E000000000000000000A3 /* GesturePathRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5E000000000000000000A1 /* GesturePathRequest.swift */; }; + FE5E000000000000000000B3 /* GesturePathRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5E000000000000000000B1 /* GesturePathRouteHandler.swift */; }; TVOS003E2DF24A000000003E /* RouteHandlerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943A9080293F5C2500C85136 /* RouteHandlerFactory.swift */; }; TVOS003F2DF24A000000003F /* TimeoutHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941AE8EE2A77D4B20097C02A /* TimeoutHelper.swift */; }; TVOS00402DF24A0000000040 /* ViewHierarchyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6124329B2A4B368100F5F619 /* ViewHierarchyRequest.swift */; }; @@ -279,6 +283,8 @@ 32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAppRequest.swift; sourceTree = ""; }; 32ECCB252980449200A1A0A0 /* TouchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchRequest.swift; sourceTree = ""; }; 32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchRouteHandler.swift; sourceTree = ""; }; + FE5E000000000000000000A1 /* GesturePathRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturePathRequest.swift; sourceTree = ""; }; + FE5E000000000000000000B1 /* GesturePathRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturePathRouteHandler.swift; sourceTree = ""; }; 52047F772A7A638E00BF982D /* StatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHandler.swift; sourceTree = ""; }; 52049BD32935039F00807AA3 /* conductor-driver-ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "conductor-driver-ios.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 52049BD62935039F00807AA3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -793,6 +799,7 @@ 32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */, 9407362D2940CA1600A72E99 /* GetRunningAppRequest.swift */, 32762AF92966DC8300FB69BD /* SwipeRequest.swift */, + FE5E000000000000000000A1 /* GesturePathRequest.swift */, 32097964297092A800340282 /* InputTextRequest.swift */, 32ECCB252980449200A1A0A0 /* TouchRequest.swift */, 61C0AFE429C7AAB3005D1FC5 /* PressKeyRequest.swift */, @@ -835,6 +842,7 @@ 61A79B9629DF0B8A00C38882 /* SwipeRouteHandlerV2.swift */, 320979622970925500340282 /* InputTextRouteHandler.swift */, 32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */, + FE5E000000000000000000B1 /* GesturePathRouteHandler.swift */, 9494CBD02982F719009C987C /* ScreenshotHandler.swift */, 94256BAC298D39DE00CDB55D /* ScreenDiffHandler.swift */, 61C0AFE629C7AB0C005D1FC5 /* PressKeyHandler.swift */, @@ -1267,6 +1275,8 @@ 61C0AFF729D34FEA005D1FC5 /* EventTarget.swift in Sources */, 612DE414298426EF003C2BE0 /* EventRecord.swift in Sources */, 32ECCB28298044C200A1A0A0 /* TouchRouteHandler.swift in Sources */, + FE5E000000000000000000A2 /* GesturePathRequest.swift in Sources */, + FE5E000000000000000000B2 /* GesturePathRouteHandler.swift in Sources */, 943A9081293F5C2500C85136 /* RouteHandlerFactory.swift in Sources */, 941AE8EF2A77D4B20097C02A /* TimeoutHelper.swift in Sources */, 6124329C2A4B368100F5F619 /* ViewHierarchyRequest.swift in Sources */, @@ -1377,6 +1387,8 @@ TVOS003B2DF24A000000003B /* EventTarget.swift in Sources */, TVOS003C2DF24A000000003C /* EventRecord.swift in Sources */, TVOS003D2DF24A000000003D /* TouchRouteHandler.swift in Sources */, + FE5E000000000000000000A3 /* GesturePathRequest.swift in Sources */, + FE5E000000000000000000B3 /* GesturePathRouteHandler.swift in Sources */, TVOS003E2DF24A000000003E /* RouteHandlerFactory.swift in Sources */, TVOS003F2DF24A000000003F /* TimeoutHelper.swift in Sources */, TVOS00402DF24A0000000040 /* ViewHierarchyRequest.swift in Sources */, diff --git a/packages/ios-driver/conductor-driver-iosUITests/Routes/Handlers/GesturePathRouteHandler.swift b/packages/ios-driver/conductor-driver-iosUITests/Routes/Handlers/GesturePathRouteHandler.swift new file mode 100644 index 0000000..f4121c9 --- /dev/null +++ b/packages/ios-driver/conductor-driver-iosUITests/Routes/Handlers/GesturePathRouteHandler.swift @@ -0,0 +1,70 @@ +import FlyingFox +import XCTest +import os + +@MainActor +struct GesturePathRouteHandler: HTTPHandler { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: Self.self) + ) + + func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse { + guard let body = try? await JSONDecoder().decode(GesturePathRequest.self, from: request.bodyData) else { + return AppError(type: .precondition, message: "incorrect request body for gesturePath").httpResponse + } + if body.paths.isEmpty { + return AppError(type: .precondition, message: "gesturePath requires at least one finger path").httpResponse + } + for (i, finger) in body.paths.enumerated() { + if finger.steps.isEmpty { + return AppError(type: .precondition, message: "gesturePath finger \(i) has no steps").httpResponse + } + } + + do { + try await dispatch(body) + return HTTPResponse(statusCode: .ok) + } catch { + return AppError(message: "gesturePath failed: \(error.localizedDescription)").httpResponse + } + } + + private func dispatch(_ request: GesturePathRequest) async throws { + let (width, height) = ScreenSizeHelper.physicalScreenSize() + let style: EventRecord.Style = request.paths.count > 1 ? .multiFinger : .singleFinger + let pathCount = request.paths.count + let description = "Gesture: \(pathCount) finger path\(pathCount == 1 ? "" : "s")" + logger.info("\(description)") + + let target = EventTarget() + try await target.dispatchEvent(description: description) { + let record = EventRecord(orientation: .portrait, style: style) + for finger in request.paths { + // First step: touch down at its (x, y) with `dt` as initial offset. + let firstStep = finger.steps[0] + let firstPoint = ScreenSizeHelper.orientationAwarePoint( + width: width, + height: height, + point: CGPoint(x: firstStep.x, y: firstStep.y) + ) + var path = PointerEventPath.pathForTouch(at: firstPoint, offset: firstStep.dt) + // Subsequent steps: advance offset by `dt`, move to point. + for i in 1.. Date: Mon, 18 May 2026 19:47:01 +0200 Subject: [PATCH 03/14] feat(cli): pinch, rotate-gesture, gesture commands Module: packages/cli/src/commands/gestures.ts. Builds synchronized multi-finger paths and dispatches them through the new driver gesturePath routes added in the previous commit. - pinch: two fingers along an axis (--angle), span scaled by --scale - rotate: two fingers traced along an arc of --degrees - gesture: arbitrary multi-finger JSON path IOSDriver and AndroidDriver gain gesturePath() methods. Dispatcher wiring lands in a later commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/gestures.ts | 263 ++++++++++++++++++++++++++ packages/cli/src/drivers/android.ts | 13 ++ packages/cli/src/drivers/ios.ts | 13 ++ 3 files changed, 289 insertions(+) create mode 100644 packages/cli/src/commands/gestures.ts diff --git a/packages/cli/src/commands/gestures.ts b/packages/cli/src/commands/gestures.ts new file mode 100644 index 0000000..ca48a31 --- /dev/null +++ b/packages/cli/src/commands/gestures.ts @@ -0,0 +1,263 @@ +export const HELP = ` pinch [--scale N] [--center x,y] [--duration ms] [--angle deg] + Two-finger pinch (scale<1 zoom out, scale>1 zoom in) + rotate-gesture [--degrees N] [--center x,y] [--duration ms] + Two-finger rotate gesture + gesture Play a multi-touch path + JSON: [{"steps":[{"x":,"y":,"dt":}]},...] + dt is delay since previous step (seconds for iOS, see docs)`; + +import fs from 'fs'; +import { runDirect } from '../runner.js'; +import { printError, printSuccess, OutputOptions } from '../output.js'; +import { IOSDriver } from '../drivers/ios.js'; +import { AndroidDriver } from '../drivers/android.js'; +import { WebDriver } from '../drivers/web.js'; + +export interface PinchOptions { + scale?: number; + center?: string; + duration?: number; + angle?: number; +} + +export interface RotateOptions { + degrees?: number; + center?: string; + duration?: number; +} + +interface FingerStep { + x: number; + y: number; + /** Delay since the previous step. */ + dt: number; +} + +interface FingerPath { + steps: FingerStep[]; +} + +function parseCenter(s: string | undefined): { x: number; y: number } | null { + if (!s) return null; + const m = s.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/); + if (!m) return null; + return { x: Number(m[1]), y: Number(m[2]) }; +} + +async function screenCenter( + driver: IOSDriver | AndroidDriver | WebDriver +): Promise<{ width: number; height: number; cx: number; cy: number }> { + if (driver instanceof IOSDriver) { + const info = await driver.deviceInfo(); + return { + width: info.widthPoints, + height: info.heightPoints, + cx: info.widthPoints / 2, + cy: info.heightPoints / 2, + }; + } + const info = await driver.deviceInfo(); + return { + width: info.widthPixels, + height: info.heightPixels, + cx: info.widthPixels / 2, + cy: info.heightPixels / 2, + }; +} + +/** + * Build two synchronized finger paths for a pinch. The two fingers start at + * `startDistance` apart, end at `endDistance`, along an axis rotated by + * `angleDeg`. We emit a coarse path (~60fps) — the drivers interpolate inside. + * + * Distances are in pixels (iOS uses points, Android uses pixels — caller + * passes the raw value; we don't normalize). + */ +function buildPinchPaths( + cx: number, + cy: number, + startDist: number, + endDist: number, + angleDeg: number, + durationMs: number +): FingerPath[] { + const rad = (angleDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const steps = Math.max(2, Math.round(durationMs / 16)); + const dtStep = durationMs / steps / 1000; + + const finger1: FingerStep[] = []; + const finger2: FingerStep[] = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const dist = startDist + (endDist - startDist) * t; + const half = dist / 2; + const dx = half * cos; + const dy = half * sin; + const dt = i === 0 ? 0 : dtStep; + finger1.push({ x: cx - dx, y: cy - dy, dt }); + finger2.push({ x: cx + dx, y: cy + dy, dt }); + } + return [{ steps: finger1 }, { steps: finger2 }]; +} + +function buildRotatePaths( + cx: number, + cy: number, + radius: number, + degrees: number, + durationMs: number +): FingerPath[] { + const steps = Math.max(2, Math.round(durationMs / 16)); + const dtStep = durationMs / steps / 1000; + const finger1: FingerStep[] = []; + const finger2: FingerStep[] = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const theta = (degrees * Math.PI * t) / 180; + const dx = radius * Math.cos(theta); + const dy = radius * Math.sin(theta); + const dt = i === 0 ? 0 : dtStep; + finger1.push({ x: cx - dx, y: cy - dy, dt }); + finger2.push({ x: cx + dx, y: cy + dy, dt }); + } + return [{ steps: finger1 }, { steps: finger2 }]; +} + +async function playPaths( + driver: IOSDriver | AndroidDriver, + paths: FingerPath[] +): Promise { + if (driver instanceof IOSDriver) { + await driver.gesturePath(paths); + } else { + // Android proto uses dt_ms (ms), our internal model uses seconds. + const androidPaths = paths.map((p) => ({ + steps: p.steps.map((s) => ({ x: s.x, y: s.y, dt_ms: Math.round(s.dt * 1000) })), + })); + await driver.gesturePath(androidPaths); + } +} + +export async function pinch( + opts: OutputOptions, + sessionName: string, + pinchOpts: PinchOptions +): Promise { + const scale = pinchOpts.scale ?? 0.5; + const duration = pinchOpts.duration ?? 400; + const angle = pinchOpts.angle ?? 0; + const result = await runDirect(async (driver) => { + if (driver instanceof WebDriver) throw new Error('pinch is not supported on Web'); + const { width, height, cx: defCx, cy: defCy } = await screenCenter(driver); + const center = parseCenter(pinchOpts.center) ?? { x: defCx, y: defCy }; + // Start span is half the shorter screen dimension; end is scaled. + const baseSpan = Math.min(width, height) * 0.5; + const startDist = scale < 1 ? baseSpan : baseSpan * scale; + const endDist = scale < 1 ? baseSpan * scale : baseSpan; + const paths = buildPinchPaths(center.x, center.y, startDist, endDist, angle, duration); + await playPaths(driver as IOSDriver | AndroidDriver, paths); + }, sessionName); + + if (result.success) { + printSuccess('pinch — done', opts); + return 0; + } + printError(`pinch — failed\n${result.stderr}`, opts); + return 1; +} + +export async function rotateGesture( + opts: OutputOptions, + sessionName: string, + rotateOpts: RotateOptions +): Promise { + const degrees = rotateOpts.degrees ?? 90; + const duration = rotateOpts.duration ?? 500; + const result = await runDirect(async (driver) => { + if (driver instanceof WebDriver) throw new Error('rotate-gesture is not supported on Web'); + const { width, height, cx: defCx, cy: defCy } = await screenCenter(driver); + const center = parseCenter(rotateOpts.center) ?? { x: defCx, y: defCy }; + const radius = Math.min(width, height) * 0.25; + const paths = buildRotatePaths(center.x, center.y, radius, degrees, duration); + await playPaths(driver as IOSDriver | AndroidDriver, paths); + }, sessionName); + + if (result.success) { + printSuccess('rotate-gesture — done', opts); + return 0; + } + printError(`rotate-gesture — failed\n${result.stderr}`, opts); + return 1; +} + +interface InputTouchPoint { + x: number; + y: number; + /** Either dt (seconds) or t (cumulative ms). dt wins if both present. */ + dt?: number; + t?: number; +} + +interface InputTrack { + /** Optional id — ignored by the driver but preserved in errors. */ + id?: number; + points?: InputTouchPoint[]; + steps?: InputTouchPoint[]; +} + +function inputToPaths(tracks: InputTrack[]): FingerPath[] { + return tracks.map((t) => { + const pts = t.steps ?? t.points ?? []; + let prevT = 0; + return { + steps: pts.map((p) => { + let dt = p.dt; + if (dt === undefined && p.t !== undefined) { + dt = (p.t - prevT) / 1000; + prevT = p.t; + } + return { x: p.x, y: p.y, dt: dt ?? 0 }; + }), + }; + }); +} + +function readGestureInput(raw: string | undefined, filePath?: string): InputTrack[] { + let json: string; + if (filePath) json = fs.readFileSync(filePath, 'utf-8'); + else if (raw) json = raw; + else throw new Error('gesture requires a JSON argument or --file '); + const parsed = JSON.parse(json) as InputTrack[]; + if (!Array.isArray(parsed)) throw new Error('gesture input must be a JSON array of tracks'); + return parsed; +} + +export async function gesture( + rawJson: string | undefined, + filePath: string | undefined, + opts: OutputOptions, + sessionName: string +): Promise { + let tracks: InputTrack[]; + try { + tracks = readGestureInput(rawJson, filePath); + } catch (err) { + printError(`gesture — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } + const paths = inputToPaths(tracks); + + const result = await runDirect(async (driver) => { + if (driver instanceof WebDriver) throw new Error('gesture is not supported on Web'); + await playPaths(driver as IOSDriver | AndroidDriver, paths); + }, sessionName); + + if (result.success) { + printSuccess(`gesture — played ${tracks.length} track(s)`, opts); + return 0; + } + printError(`gesture — failed\n${result.stderr}`, opts); + return 1; +} diff --git a/packages/cli/src/drivers/android.ts b/packages/cli/src/drivers/android.ts index 9114d30..ddcc819 100644 --- a/packages/cli/src/drivers/android.ts +++ b/packages/cli/src/drivers/android.ts @@ -97,6 +97,19 @@ export class AndroidDriver { await this.call('tap', { x: Math.round(x), y: Math.round(y) }); } + /** + * Multi-finger gesture playback. Driver injects multi-pointer MotionEvents + * via UiAutomation.injectInputEvent — one pointer per path, resampled to a + * shared 16ms grid so all fingers move on the same clock. `dt_ms` is the + * delay since this finger's previous step (or initial offset for the first). + */ + async gesturePath( + paths: Array<{ steps: Array<{ x: number; y: number; dt_ms: number }> }>, + timeoutMs = 35000 + ): Promise { + await this.call('gesturePath', { paths }, timeoutMs); + } + async inputText(text: string): Promise { await this.call('inputText', { text }); } diff --git a/packages/cli/src/drivers/ios.ts b/packages/cli/src/drivers/ios.ts index 672be4c..3a07827 100644 --- a/packages/cli/src/drivers/ios.ts +++ b/packages/cli/src/drivers/ios.ts @@ -183,6 +183,19 @@ export class IOSDriver { }); } + /** + * Multi-finger gesture playback. `paths` is one entry per finger; each entry's + * `steps` are the (x, y, dt) frames making up that finger's path. The driver + * synthesizes a multi-finger XCSynthesizedEventRecord when paths.length > 1. + * `dt` is the delay in seconds since the previous step (or the initial offset + * for the first step). + */ + async gesturePath( + paths: Array<{ steps: Array<{ x: number; y: number; dt: number }> }> + ): Promise { + await this.post('gesturePath', { paths }); + } + async inputText(text: string, appIds: string[] = []): Promise { await this.post('inputText', { text, appIds }); } From e61dd924c9873ca70c423e59648d7d37d39a045b Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:47:14 +0200 Subject: [PATCH 04/14] feat(cli): iOS-only clipboard + paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clipboard read/write via xcrun simctl pbcopy/pbpaste on the target UDID. - paste types the clipboard contents into the focused field (iOS has no universal Cmd+V; the read+input-text pattern matches Argent's approach for their custom simulator-server paste command). Android is explicitly unsupported — `cmd clipboard set-primary-clip` is API 31+ and brittle. Callers get a clear "use input-text instead" message matching Argent's iOS-only paste tool surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/clipboard.ts | 81 ++++++++++++++++++++++++++ packages/cli/src/drivers/ios.ts | 25 ++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/cli/src/commands/clipboard.ts diff --git a/packages/cli/src/commands/clipboard.ts b/packages/cli/src/commands/clipboard.ts new file mode 100644 index 0000000..45128c3 --- /dev/null +++ b/packages/cli/src/commands/clipboard.ts @@ -0,0 +1,81 @@ +export const HELP = ` clipboard read Print the iOS simulator clipboard + clipboard write Set the iOS simulator clipboard + paste Paste the clipboard into the focused field (iOS only)`; + +import { runDirect } from '../runner.js'; +import { printSuccess, printError, printData, OutputOptions } from '../output.js'; +import { IOSDriver } from '../drivers/ios.js'; +import { AndroidDriver } from '../drivers/android.js'; +import { WebDriver } from '../drivers/web.js'; + +const ANDROID_MSG = + 'clipboard is iOS-only. On Android, use `conductor input-text` to type instead.'; + +export async function clipboardRead( + opts: OutputOptions = {}, + sessionName = 'default' +): Promise { + const result = await runDirect(async (driver) => { + if (driver instanceof IOSDriver) return await driver.clipboardRead(); + if (driver instanceof AndroidDriver) throw new Error(ANDROID_MSG); + if (driver instanceof WebDriver) throw new Error('clipboard read is not supported on Web'); + return ''; + }, sessionName); + + if (result.success) { + if (opts.json) printData({ text: result.stdout }, opts); + else process.stdout.write(result.stdout); + return 0; + } else { + printError(`clipboard read — failed\n${result.stderr}`, opts); + return 1; + } +} + +export async function clipboardWrite( + text: string, + opts: OutputOptions = {}, + sessionName = 'default' +): Promise { + const result = await runDirect(async (driver) => { + if (driver instanceof IOSDriver) { + await driver.clipboardWrite(text); + } else if (driver instanceof AndroidDriver) { + throw new Error(ANDROID_MSG); + } else if (driver instanceof WebDriver) { + throw new Error('clipboard write is not supported on Web'); + } + }, sessionName); + + if (result.success) { + printSuccess('clipboard write — done', opts); + return 0; + } else { + printError(`clipboard write — failed\n${result.stderr}`, opts); + return 1; + } +} + +export async function paste(opts: OutputOptions = {}, sessionName = 'default'): Promise { + const result = await runDirect(async (driver) => { + if (driver instanceof IOSDriver) { + // iOS has no universal OS-level paste — read the clipboard and type it into + // the focused field. For app-specific Cmd+V handling, callers should issue + // press-key paste explicitly via the keyboard route. + const text = await driver.clipboardRead(); + if (text) await driver.inputText(text); + } else if (driver instanceof AndroidDriver) { + throw new Error('paste is iOS-only. On Android, use `conductor input-text` instead.'); + } else if (driver instanceof WebDriver) { + throw new Error('paste is not supported on Web'); + } + }, sessionName); + + if (result.success) { + printSuccess('paste — done', opts); + return 0; + } else { + printError(`paste — failed\n${result.stderr}`, opts); + return 1; + } +} diff --git a/packages/cli/src/drivers/ios.ts b/packages/cli/src/drivers/ios.ts index 3a07827..9b3e2f5 100644 --- a/packages/cli/src/drivers/ios.ts +++ b/packages/cli/src/drivers/ios.ts @@ -265,6 +265,31 @@ export class IOSDriver { await this.simctl(['openurl', deviceId, url]); } + /** Read the simulator's clipboard. Uses `xcrun simctl pbpaste `. */ + async clipboardRead(): Promise { + const deviceId = this.requireDeviceId(); + return this.simctlCapture(['pbpaste', deviceId]); + } + + /** Write to the simulator's clipboard. Uses `xcrun simctl pbcopy ` over stdin. */ + async clipboardWrite(text: string): Promise { + const deviceId = this.requireDeviceId(); + await new Promise((resolve, reject) => { + const proc = spawn('xcrun', ['simctl', 'pbcopy', deviceId], { + stdio: ['pipe', 'ignore', 'pipe'], + }); + let stderr = ''; + proc.stderr?.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`xcrun simctl pbcopy failed: ${stderr.trim()}`)) + ); + proc.on('error', reject); + proc.stdin?.end(text); + }); + } + async setLocation(latitude: number, longitude: number): Promise { const deviceId = this.requireDeviceId(); await this.simctl(['location', deviceId, 'set', `${latitude},${longitude}`]); From d5ef4edaff11eef128699a7438640fe374f2b88a Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:47:24 +0200 Subject: [PATCH 05/14] feat(cli): inspect --at point queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `inspect` with `--at x,y` and `--tappable` flags. Adds findIOSViewAtPoint / findAndroidViewAtPoint / findWebViewAtPoint to element-resolver — depth-first walks returning the deepest (smallest-area) node whose rect contains the point, optionally filtered to interactive elements (iOS XCUIElementType, Android clickable, Web aria roles). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/inspect.ts | 48 +++- packages/cli/src/drivers/element-resolver.ts | 221 +++++++++++++++++++ 2 files changed, 267 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index ce2125d..b4c4f9e 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -1,7 +1,8 @@ -export const HELP = ` inspect [--dump] Print UI hierarchy (--dump for raw driver output)`; +export const HELP = ` inspect [--dump] Print UI hierarchy (--dump for raw driver output) + inspect --at [--tappable] Print the topmost view at a screen point`; import { getDriver } from '../runner.js'; -import { printError, OutputOptions } from '../output.js'; +import { printError, printData, OutputOptions } from '../output.js'; import { IOSDriver } from '../drivers/ios.js'; import { AndroidDriver } from '../drivers/android.js'; import { WebDriver } from '../drivers/web.js'; @@ -9,11 +10,18 @@ import { inspectIOSToText, inspectAndroidToText, inspectWebToText, + findIOSViewAtPoint, + findAndroidViewAtPoint, + findWebViewAtPoint, } from '../drivers/element-resolver.js'; import { buildIOSA11y, buildAndroidA11y, buildWebA11y } from '../drivers/a11y.js'; export interface InspectOptions { dump?: boolean; + /** Point query: "x,y" pixel coordinates. */ + at?: string; + /** When `at` is set, restrict to tappable/interactive elements. */ + tappableOnly?: boolean; } export async function inspect( @@ -24,6 +32,42 @@ export async function inspect( try { const driver = await getDriver(sessionName); + if (inspectOpts.at) { + const m = inspectOpts.at.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/); + if (!m) { + printError(`inspect --at expects ",", got "${inspectOpts.at}"`, opts); + return 1; + } + const x = Number(m[1]); + const y = Number(m[2]); + const tappable = !!inspectOpts.tappableOnly; + + let hit; + if (driver instanceof IOSDriver) { + const hierarchy = await driver.viewHierarchy(false); + hit = findIOSViewAtPoint(hierarchy.axElement, x, y, tappable); + } else if (driver instanceof AndroidDriver) { + const xml = await driver.viewHierarchy(); + hit = findAndroidViewAtPoint(xml, x, y, tappable); + } else if (driver instanceof WebDriver) { + const vh = await driver.viewHierarchy(); + hit = findWebViewAtPoint(vh, x, y, tappable); + } else { + throw new Error('Unknown driver type'); + } + + if (!hit) { + const message = `No ${tappable ? 'tappable view' : 'view'} found at (${x}, ${y})`; + if (opts.json) printData({ found: false, x, y, tappable }, opts); + else console.log(message); + return tappable ? 1 : 0; + } + + if (opts.json) printData({ found: true, x, y, tappable, hit }, opts); + else console.log(hit.summary); + return 0; + } + if (inspectOpts.dump) { let raw: string; if (driver instanceof IOSDriver) { diff --git a/packages/cli/src/drivers/element-resolver.ts b/packages/cli/src/drivers/element-resolver.ts index b8f7e2b..158f524 100644 --- a/packages/cli/src/drivers/element-resolver.ts +++ b/packages/cli/src/drivers/element-resolver.ts @@ -695,3 +695,224 @@ function visitWeb(nodes: WebElement[], lines: string[], depth: number): void { } } } + +// ── Point-based hierarchy queries ───────────────────────────────────────────── + +export interface PointHit { + /** Indented one-line summary, same shape as the per-line inspect output. */ + summary: string; + /** Pixel rect of the matched node. */ + rect: { x: number; y: number; width: number; height: number }; + /** Optional identifiers — populated when available on the platform. */ + id?: string; + text?: string; + role?: string; + enabled?: boolean; + /** Whether the node looks tappable (clickable on Android, enabled on iOS/Web). */ + tappable: boolean; +} + +function iosRectAt(el: AXElement): { x: number; y: number; width: number; height: number } { + return { x: el.frame.X, y: el.frame.Y, width: el.frame.Width, height: el.frame.Height }; +} + +function iosContains(el: AXElement, x: number, y: number): boolean { + const r = iosRectAt(el); + return x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height; +} + +// iOS element types that are typically interactive (XCUIElementType enum values). +// 9=button, 50=staticText skipped, 49=textField, 65=secureTextField, 14=switch, 38=slider, etc. +const IOS_TAPPABLE_TYPES = new Set([9, 49, 65, 14, 38, 51, 52, 76, 53, 55, 56, 57]); + +function iosNodeTappable(el: AXElement): boolean { + return el.enabled && IOS_TAPPABLE_TYPES.has(el.elementType); +} + +function iosSummarize(el: AXElement): string { + const parts = [`type=${el.elementType}`]; + if (el.identifier) parts.push(`id=${el.identifier}`); + if (el.label) parts.push(`label="${el.label}"`); + if (el.value) parts.push(`value="${String(el.value)}"`); + const r = iosRectAt(el); + parts.push( + `bounds=[${Math.round(r.x)},${Math.round(r.y)}][${Math.round(r.x + r.width)},${Math.round(r.y + r.height)}]` + ); + if (!el.enabled) parts.push('disabled'); + return parts.join(' '); +} + +/** + * Walk the iOS AX tree depth-first and return the deepest element whose frame + * contains (x,y). When `tappableOnly` is true, restrict to elements that look + * interactive (button, text field, switch, slider, etc.). + */ +export function findIOSViewAtPoint( + root: AXElement, + x: number, + y: number, + tappableOnly = false +): PointHit | null { + let best: AXElement | null = null; + let bestArea = Infinity; + + function visit(node: AXElement): void { + if (!iosContains(node, x, y)) return; + if (!tappableOnly || iosNodeTappable(node)) { + const r = iosRectAt(node); + const area = r.width * r.height; + if (area <= bestArea) { + bestArea = area; + best = node; + } + } + if (node.children) { + for (const child of node.children) visit(child); + } + } + + visit(root); + if (!best) return null; + const matched = best as AXElement; + return { + summary: iosSummarize(matched), + rect: iosRectAt(matched), + id: matched.identifier || undefined, + text: matched.label || (matched.value as string | undefined), + enabled: matched.enabled, + tappable: iosNodeTappable(matched), + }; +} + +function androidContains(n: AndroidNode, x: number, y: number): boolean { + return x >= n.bounds.x1 && x < n.bounds.x2 && y >= n.bounds.y1 && y < n.bounds.y2; +} + +function androidRect(n: AndroidNode): { x: number; y: number; width: number; height: number } { + return { + x: n.bounds.x1, + y: n.bounds.y1, + width: n.bounds.x2 - n.bounds.x1, + height: n.bounds.y2 - n.bounds.y1, + }; +} + +function androidSummarize(n: AndroidNode): string { + const parts: string[] = []; + if (n.className) parts.push(`class=${n.className}`); + if (n.resourceId) parts.push(`id=${n.resourceId}`); + if (n.text) parts.push(`text="${n.text}"`); + if (n.contentDesc) parts.push(`desc="${n.contentDesc}"`); + parts.push(`bounds=[${n.bounds.x1},${n.bounds.y1}][${n.bounds.x2},${n.bounds.y2}]`); + if (n.clickable) parts.push('clickable'); + if (!n.enabled) parts.push('disabled'); + return parts.join(' '); +} + +export function findAndroidViewAtPoint( + xml: string, + x: number, + y: number, + tappableOnly = false +): PointHit | null { + const nodes = parseAndroidHierarchy(xml); + let best: AndroidNode | null = null; + let bestArea = Infinity; + for (const n of nodes) { + if (!androidContains(n, x, y)) continue; + if (tappableOnly && !n.clickable) continue; + const r = androidRect(n); + const area = r.width * r.height; + if (area <= bestArea) { + bestArea = area; + best = n; + } + } + if (!best) return null; + const matched = best as AndroidNode; + return { + summary: androidSummarize(matched), + rect: androidRect(matched), + id: matched.resourceId || undefined, + text: matched.text || matched.contentDesc || undefined, + enabled: matched.enabled, + tappable: matched.clickable, + }; +} + +const WEB_TAPPABLE_ROLES = new Set([ + 'button', + 'link', + 'checkbox', + 'radio', + 'menuitem', + 'tab', + 'option', + 'switch', + 'textbox', + 'searchbox', + 'combobox', + 'slider', + 'spinbutton', +]); + +function webRectContains( + b: { x: number; y: number; width: number; height: number }, + x: number, + y: number +): boolean { + return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height; +} + +function webNodeTappable(node: WebElement): boolean { + return node.enabled && WEB_TAPPABLE_ROLES.has(node.role); +} + +function webSummarize(node: WebElement): string { + const parts = [`role=${node.role}`]; + if (node.name) parts.push(`name="${node.name}"`); + if (node.ref) parts.push(`ref=${node.ref}`); + if (node.bounds) { + const b = node.bounds; + parts.push( + `bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]` + ); + } + if (!node.enabled) parts.push('disabled'); + return parts.join(' '); +} + +export function findWebViewAtPoint( + hierarchy: WebViewHierarchy, + x: number, + y: number, + tappableOnly = false +): PointHit | null { + let best: WebElement | null = null; + let bestArea = Infinity; + + function visit(node: WebElement): void { + if (node.bounds && webRectContains(node.bounds, x, y)) { + if (!tappableOnly || webNodeTappable(node)) { + const area = node.bounds.width * node.bounds.height; + if (area <= bestArea) { + bestArea = area; + best = node; + } + } + } + if (node.children) for (const c of node.children) visit(c); + } + + for (const root of hierarchy.elements) visit(root); + if (!best) return null; + const matched = best as WebElement; + return { + summary: webSummarize(matched), + rect: matched.bounds ?? { x: 0, y: 0, width: 0, height: 0 }, + text: matched.name || undefined, + role: matched.role, + enabled: matched.enabled, + tappable: webNodeTappable(matched), + }; +} From 9d90e609e6178908025f82191971f1d71ec13129 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:47:35 +0200 Subject: [PATCH 06/14] feat(cli): metro stop / reload + reusable CDP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/cli/src/drivers/metro-cdp.ts: one-shot cdpCall() and stateful MetroCdpClient. Reuses fetchTargets / selectTargetForDevice from the existing MetroLogSource discovery code instead of duplicating it. Includes Runtime.evaluate, Runtime.addBinding for async callbacks, and per-domain enable tracking — used here by metro reload, later by the experimental debug/network/profile commands. - packages/cli/src/commands/metro.ts: metro stop — lsof -ti tcp: + SIGTERM (escalate to SIGKILL). metro reload — Page.reload over CDP, falls back to POST /reload. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/metro.ts | 112 +++++++++ packages/cli/src/drivers/metro-cdp.ts | 333 ++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 packages/cli/src/commands/metro.ts create mode 100644 packages/cli/src/drivers/metro-cdp.ts diff --git a/packages/cli/src/commands/metro.ts b/packages/cli/src/commands/metro.ts new file mode 100644 index 0000000..99cb029 --- /dev/null +++ b/packages/cli/src/commands/metro.ts @@ -0,0 +1,112 @@ +export const HELP = ` metro stop [--port N] Stop the Metro bundler process on a port (default 8081) + metro reload [--port N] [--target N] Reload the JS bundle without restarting native`; + +import { spawn } from 'child_process'; +import { printSuccess, printError, printData, OutputOptions } from '../output.js'; +import { cdpCall } from '../drivers/metro-cdp.js'; +import { detectPlatform } from '../drivers/bootstrap.js'; + +export interface MetroOptions { + port?: number; + targetIndex?: number; +} + +async function pidsOnPort(port: number): Promise { + return new Promise((resolve) => { + const proc = spawn('lsof', ['-ti', `tcp:${port}`], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + let out = ''; + proc.stdout.on('data', (c: Buffer) => { + out += c.toString(); + }); + proc.on('close', () => { + const pids = out + .split('\n') + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0); + resolve(pids); + }); + proc.on('error', () => resolve([])); + }); +} + +export async function metroStop(opts: OutputOptions, metroOpts: MetroOptions): Promise { + const port = metroOpts.port ?? 8081; + const pids = await pidsOnPort(port); + + if (pids.length === 0) { + printData({ stopped: false, port, pids: [] }, opts); + if (!opts.json) printSuccess(`No Metro process found on port ${port}`, opts); + return 0; + } + + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may already be gone + } + } + + // Give SIGTERM 2s, then SIGKILL anything still alive. + await new Promise((r) => setTimeout(r, 2000)); + const survivors = await pidsOnPort(port); + for (const pid of survivors) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } + } + + if (opts.json) { + printData({ stopped: true, port, pids }, opts); + } else { + printSuccess(`Stopped Metro on port ${port} (pids: ${pids.join(', ')})`, opts); + } + return 0; +} + +export async function metroReload( + opts: OutputOptions, + sessionName: string, + metroOpts: MetroOptions +): Promise { + const port = metroOpts.port ?? 8081; + let deviceId: string | undefined; + let platform: string | undefined; + if (sessionName && sessionName !== 'default') { + deviceId = sessionName; + platform = await detectPlatform(deviceId).catch(() => undefined); + } + + // Try CDP Page.reload first (works on Hermes/Fusebox). + try { + await cdpCall('Page.reload', undefined, { + port, + deviceId, + platform, + targetIndex: metroOpts.targetIndex, + }); + if (opts.json) printData({ reloaded: true, port, method: 'cdp' }, opts); + else printSuccess(`Reloaded Metro bundle on port ${port} (cdp)`, opts); + return 0; + } catch (cdpErr) { + // Fall back to Metro's HTTP /reload endpoint. + try { + const res = await fetch(`http://127.0.0.1:${port}/reload`); + if (!res.ok) { + throw new Error(`HTTP /reload returned ${res.status}`); + } + if (opts.json) printData({ reloaded: true, port, method: 'http' }, opts); + else printSuccess(`Reloaded Metro bundle on port ${port} (http)`, opts); + return 0; + } catch (httpErr) { + const cdpMsg = cdpErr instanceof Error ? cdpErr.message : String(cdpErr); + const httpMsg = httpErr instanceof Error ? httpErr.message : String(httpErr); + printError(`metro reload failed.\n cdp: ${cdpMsg}\n http: ${httpMsg}`, opts); + return 1; + } + } +} diff --git a/packages/cli/src/drivers/metro-cdp.ts b/packages/cli/src/drivers/metro-cdp.ts new file mode 100644 index 0000000..ab73595 --- /dev/null +++ b/packages/cli/src/drivers/metro-cdp.ts @@ -0,0 +1,333 @@ +/** + * One-shot CDP client to Metro's debugger endpoint. + * + * Sibling to `MetroLogSource` in log-sources/metro.ts: that class stays connected + * to stream `Runtime.consoleAPICalled`; this one opens a short-lived socket for + * request/response calls (`Page.reload`, `Runtime.evaluate`, etc.). + * + * Reuses `fetchTargets()` / target selection from `log-sources/metro.ts` and + * `metro-discovery.ts` — do not duplicate discovery logic here. + */ +import WebSocket from 'ws'; +import { fetchTargets } from './log-sources/metro.js'; +import { selectTargetForDevice, getDeviceDisplayName } from './log-sources/metro-discovery.js'; + +export interface CdpCallOptions { + /** Metro server port. Defaults to 8081. */ + port?: number; + /** Metro host. Defaults to localhost. */ + host?: string; + /** Device ID for target selection. When omitted, picks the first available target. */ + deviceId?: string; + /** Platform of `deviceId`, needed to resolve the device's display name. */ + platform?: string; + /** Index into the unfiltered target list — overrides device-based selection. */ + targetIndex?: number; + /** Per-call timeout (ms). Default 10s. */ + timeoutMs?: number; +} + +interface CdpResponse { + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +interface CdpRequest { + id: number; + method: string; + params?: Record; +} + +/** + * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex. + * Throws with a clear message if Metro is unreachable or no target matches. + */ +export async function resolveDebuggerUrl(opts: CdpCallOptions): Promise { + const port = opts.port ?? 8081; + const host = opts.host ?? 'localhost'; + const targets = await fetchTargets(port, host); + const withWs = targets.filter((t) => t.webSocketDebuggerUrl); + + if (withWs.length === 0) { + throw new Error( + `Metro on ${host}:${port} returned no debugger targets. Is an app running on a device/simulator?` + ); + } + + if (opts.targetIndex !== undefined) { + if (opts.targetIndex < 0 || opts.targetIndex >= withWs.length) { + throw new Error(`--target ${opts.targetIndex} is out of range (have ${withWs.length}).`); + } + return withWs[opts.targetIndex].webSocketDebuggerUrl!; + } + + if (opts.deviceId && opts.platform) { + const displayName = await getDeviceDisplayName(opts.platform, opts.deviceId); + if (displayName) { + const target = selectTargetForDevice(withWs, displayName); + if (target) return target.webSocketDebuggerUrl!; + } + } + + // Prefer the Hermes/React target by title, otherwise first. + const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0]; + return target.webSocketDebuggerUrl!; +} + +/** + * Open a short-lived CDP socket, send a single method, return the result. + * Closes the socket whether the call succeeds or throws. + */ +export async function cdpCall( + method: string, + params: Record | undefined, + opts: CdpCallOptions +): Promise { + const wsUrl = await resolveDebuggerUrl(opts); + return cdpCallOnUrl(wsUrl, method, params, opts.timeoutMs ?? 10_000); +} + +async function cdpCallOnUrl( + wsUrl: string, + method: string, + params: Record | undefined, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + ws.terminate(); + reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + function finish(err: Error | null, value?: T): void { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.removeAllListeners(); + try { + ws.close(); + } catch { + // ignore + } + if (err) reject(err); + else resolve(value as T); + } + + const req: CdpRequest = { id: 1, method }; + if (params) req.params = params; + + ws.on('open', () => { + ws.send(JSON.stringify(req)); + }); + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()) as CdpResponse; + if (msg.id !== req.id) return; + if (msg.error) { + finish(new Error(`CDP ${method}: ${msg.error.message}`)); + } else { + finish(null, msg.result as T); + } + } catch (err) { + finish(err instanceof Error ? err : new Error(String(err))); + } + }); + + ws.on('error', (err) => finish(err)); + ws.on('close', () => { + if (!settled) finish(new Error(`CDP socket closed before ${method} completed`)); + }); + }); +} + +/** + * Stateful CDP client. Open once, issue many calls. Used by the debugger + * commands that need session-scoped state (loaded scripts, enabled domains). + */ +export class MetroCdpClient { + private ws: WebSocket | null = null; + private nextId = 1; + private pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + private events = new Map void>>(); + private enabledDomains = new Set(); + private loadedScripts = new Map(); + private bindings = new Set(); + private callbackPending = new Map< + string, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + + async connect(opts: CdpCallOptions): Promise { + const wsUrl = await resolveDebuggerUrl(opts); + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.on('open', () => { + this.ws = ws; + resolve(); + }); + ws.on('message', (data) => this.handleMessage(data.toString())); + ws.on('error', (err) => { + if (!this.ws) reject(err); + }); + ws.on('close', () => { + this.ws = null; + }); + }); + } + + private handleMessage(raw: string): void { + try { + const msg = JSON.parse(raw) as Partial & { method?: string; params?: unknown }; + if (typeof msg.id === 'number') { + const slot = this.pending.get(msg.id); + if (slot) { + this.pending.delete(msg.id); + if (msg.error) slot.reject(new Error(msg.error.message)); + else slot.resolve(msg.result); + } + return; + } + if (msg.method) { + if (msg.method === 'Debugger.scriptParsed') { + const p = msg.params as { scriptId: string; url: string }; + if (p?.scriptId) this.loadedScripts.set(p.scriptId, { url: p.url }); + } + const handlers = this.events.get(msg.method); + if (handlers) for (const h of handlers) h(msg.params); + } + } catch { + // ignore parse errors + } + } + + on(method: string, handler: (params: unknown) => void): void { + const arr = this.events.get(method) ?? []; + arr.push(handler); + this.events.set(method, arr); + } + + async send(method: string, params?: Record): Promise { + if (!this.ws) throw new Error('CDP client not connected'); + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (v) => resolve(v as T), + reject, + }); + this.ws!.send(JSON.stringify({ id, method, params })); + }); + } + + async enableDomain(domain: 'Runtime' | 'Debugger' | 'Page' | 'Network'): Promise { + if (this.enabledDomains.has(domain)) return; + await this.send(`${domain}.enable`); + this.enabledDomains.add(domain); + } + + /** + * Install a `Runtime.addBinding` callback. The injected JS calls + * `globalThis.__conductor_callback(JSON.stringify({ requestId, ... }))` and + * this method routes the payload back to the awaiter keyed on `requestId`. + * + * Returns a function that, given a requestId, returns a promise resolving + * to the next payload tagged with that requestId. Useful for async fiber + * walkers that can't return synchronously from `Runtime.evaluate`. + */ + async installCallbackBinding( + bindingName = '__conductor_callback' + ): Promise<(requestId: string, timeoutMs?: number) => Promise> { + await this.enableDomain('Runtime'); + if (!this.bindings.has(bindingName)) { + await this.send('Runtime.addBinding', { name: bindingName }); + this.bindings.add(bindingName); + this.on('Runtime.bindingCalled', (params) => { + const p = params as { name: string; payload: string }; + if (p.name !== bindingName) return; + try { + const parsed = JSON.parse(p.payload) as { requestId?: string }; + if (parsed.requestId && this.callbackPending.has(parsed.requestId)) { + const slot = this.callbackPending.get(parsed.requestId)!; + this.callbackPending.delete(parsed.requestId); + slot.resolve(parsed); + } + } catch { + // ignore malformed payloads + } + }); + } + return (requestId: string, timeoutMs = 5000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.callbackPending.delete(requestId); + reject(new Error(`callback ${requestId} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.callbackPending.set(requestId, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject, + }); + }); + } + + /** + * Evaluate a JS expression in the app's runtime. Returns the value or throws + * a thrown JS exception's description. Awaits promises by default. + */ + async evaluate(expression: string, returnByValue = true): Promise { + await this.enableDomain('Runtime'); + const result = await this.send<{ + result: { type: string; value?: unknown; description?: string }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }>('Runtime.evaluate', { + expression, + returnByValue, + awaitPromise: true, + generatePreview: false, + }); + if (result.exceptionDetails) { + const msg = + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + 'evaluation threw'; + throw new Error(msg); + } + return (result.result.value ?? result.result.description) as T; + } + + getEnabledDomains(): Set { + return new Set(this.enabledDomains); + } + + getLoadedScripts(): Map { + return this.loadedScripts; + } + + isConnected(): boolean { + return this.ws !== null; + } + + close(): void { + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + this.ws = null; + } + this.pending.clear(); + this.events.clear(); + } +} From ddedbe4a17baab335246a45d17ec8b888fa3ccf5 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:47:52 +0200 Subject: [PATCH 07/14] feat(cli): run-sequence + workspace info - run-sequence: batched serial dispatch of conductor commands against one session. Reads {"steps":[{"cmd","args","flags"}]} from --file or stdin; stops on first non-zero exit. Saves agent roundtrips when a multi-step flow doesn't need real flow-runner semantics. - workspace info: single read-only report of project type (RN / Expo / iOS / Android / Web / mixed), bundle ids, configured devices, current Metro port. Lets agents skip the package.json + list-devices + bundle-id derivation dance. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/run-sequence.ts | 156 ++++++++++++++++++++ packages/cli/src/commands/workspace.ts | 170 ++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 packages/cli/src/commands/run-sequence.ts create mode 100644 packages/cli/src/commands/workspace.ts diff --git a/packages/cli/src/commands/run-sequence.ts b/packages/cli/src/commands/run-sequence.ts new file mode 100644 index 0000000..2c7b74f --- /dev/null +++ b/packages/cli/src/commands/run-sequence.ts @@ -0,0 +1,156 @@ +export const HELP = ` run-sequence [--file path.json] Run a sequence of conductor commands serially against one session + JSON shape: {"steps":[{"cmd":"tap-on","args":["Login"]}, ...]} + Reads stdin when --file is omitted. Stops on first non-zero exit.`; + +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { printError, printData, OutputOptions } from '../output.js'; + +interface Step { + cmd: string; + args?: string[]; + /** Per-step flags (--key value). Useful to pass --id, --text, etc. */ + flags?: Record; +} + +interface SequenceInput { + steps: Step[]; +} + +interface StepResult { + cmd: string; + args: string[]; + exitCode: number; + stdout: string; + stderr: string; +} + +function flagsToArgs(flags: Record | undefined): string[] { + if (!flags) return []; + const out: string[] = []; + for (const [key, value] of Object.entries(flags)) { + if (value === false) continue; + out.push(`--${key}`); + if (value !== true) out.push(String(value)); + } + return out; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let buf = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + buf += chunk; + }); + process.stdin.on('end', () => resolve(buf)); + process.stdin.on('error', reject); + }); +} + +export async function runSequence( + filePath: string | undefined, + opts: OutputOptions = {}, + sessionName = 'default' +): Promise { + let raw: string; + if (filePath) { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + printError(`run-sequence: file not found: ${resolved}`, opts); + return 1; + } + raw = fs.readFileSync(resolved, 'utf-8'); + } else { + raw = await readStdin(); + } + + let parsed: SequenceInput; + try { + parsed = JSON.parse(raw) as SequenceInput; + } catch (err) { + printError( + `run-sequence: invalid JSON\n${err instanceof Error ? err.message : String(err)}`, + opts + ); + return 1; + } + + if (!parsed.steps || !Array.isArray(parsed.steps)) { + printError('run-sequence: input must be {"steps":[...]}', opts); + return 1; + } + + const results: StepResult[] = []; + const conductorBin = process.argv[1] ?? 'conductor'; + const deviceArgs = sessionName !== 'default' ? ['--device', sessionName] : []; + + for (let i = 0; i < parsed.steps.length; i++) { + const step = parsed.steps[i]; + const args = [step.cmd, ...(step.args ?? []), ...flagsToArgs(step.flags), ...deviceArgs]; + if (!opts.json) { + console.log(`[${i + 1}/${parsed.steps.length}] conductor ${args.join(' ')}`); + } + const result = await runStep(conductorBin, args); + results.push({ + cmd: step.cmd, + args: step.args ?? [], + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + if (!opts.json && result.stdout) process.stdout.write(result.stdout); + if (!opts.json && result.stderr) process.stderr.write(result.stderr); + if (result.exitCode !== 0) { + if (opts.json) { + printData({ ok: false, completed: i, total: parsed.steps.length, results }, opts); + } else { + printError( + `run-sequence aborted at step ${i + 1}/${parsed.steps.length} (${step.cmd}, exit ${result.exitCode})`, + opts + ); + } + return result.exitCode; + } + } + + if (opts.json) { + printData( + { ok: true, completed: parsed.steps.length, total: parsed.steps.length, results }, + opts + ); + } else { + console.log(`run-sequence — completed ${parsed.steps.length} step(s)`); + } + return 0; +} + +interface StepRunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function runStep(bin: string, args: string[]): Promise { + return new Promise((resolve) => { + const proc = spawn(process.execPath, [bin, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + proc.stderr.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + proc.on('close', (code) => { + resolve({ exitCode: code ?? 1, stdout, stderr }); + }); + proc.on('error', (err) => { + resolve({ exitCode: 1, stdout: '', stderr: err.message }); + }); + }); +} diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts new file mode 100644 index 0000000..4de69cb --- /dev/null +++ b/packages/cli/src/commands/workspace.ts @@ -0,0 +1,170 @@ +export const HELP = ` workspace info Print detected project type, bundle IDs, devices, Metro port`; + +import fs from 'fs'; +import path from 'path'; +import { printError, printData, OutputOptions } from '../output.js'; +import { discoverBootedDevices } from './list-devices.js'; +import { discoverMetroPortForDevice } from '../drivers/log-sources/metro-discovery.js'; + +interface WorkspaceInfo { + projectRoot: string; + projectType: 'rn' | 'expo' | 'ios' | 'android' | 'web' | 'mixed' | 'unknown'; + reactNativeVersion: string | null; + bundleIds: { + ios: string | null; + android: string | null; + }; + bundleNames: { + ios: string | null; + android: string | null; + }; + hasIosDir: boolean; + hasAndroidDir: boolean; + hasPlaywrightConfig: boolean; + configuredDevices: Array<{ id: string; name: string; platform: string }>; + metroPort: number | null; + currentSession: string; +} + +function findProjectRoot(start: string): string { + let cur = path.resolve(start); + for (let i = 0; i < 20; i++) { + if (fs.existsSync(path.join(cur, 'package.json'))) return cur; + if (fs.existsSync(path.join(cur, '.git'))) return cur; + const parent = path.dirname(cur); + if (parent === cur) break; + cur = parent; + } + return start; +} + +function readJson(file: string): Record | null { + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')) as Record; + } catch { + return null; + } +} + +function detectIosBundleId(projectRoot: string): { id: string | null; name: string | null } { + const iosDir = path.join(projectRoot, 'ios'); + if (!fs.existsSync(iosDir)) return { id: null, name: null }; + // Look for the first .xcodeproj/project.pbxproj and grep PRODUCT_BUNDLE_IDENTIFIER. + try { + const entries = fs.readdirSync(iosDir); + const xcodeproj = entries.find((e) => e.endsWith('.xcodeproj')); + if (!xcodeproj) return { id: null, name: null }; + const pbx = path.join(iosDir, xcodeproj, 'project.pbxproj'); + if (!fs.existsSync(pbx)) return { id: null, name: xcodeproj.replace('.xcodeproj', '') }; + const text = fs.readFileSync(pbx, 'utf-8'); + const m = text.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([A-Za-z0-9._-]+)"?\s*;/); + return { + id: m ? m[1] : null, + name: xcodeproj.replace('.xcodeproj', ''), + }; + } catch { + return { id: null, name: null }; + } +} + +function detectAndroidBundleId(projectRoot: string): { id: string | null; name: string | null } { + const candidates = [ + path.join(projectRoot, 'android', 'app', 'build.gradle'), + path.join(projectRoot, 'android', 'app', 'build.gradle.kts'), + ]; + for (const file of candidates) { + if (!fs.existsSync(file)) continue; + try { + const text = fs.readFileSync(file, 'utf-8'); + const idMatch = text.match(/applicationId\s+["']([A-Za-z0-9._-]+)["']/); + if (idMatch) { + return { id: idMatch[1], name: idMatch[1].split('.').pop() ?? null }; + } + } catch { + // try next + } + } + return { id: null, name: null }; +} + +export async function workspaceInfo(opts: OutputOptions = {}): Promise { + const projectRoot = findProjectRoot(process.cwd()); + const pkgPath = path.join(projectRoot, 'package.json'); + const pkg = fs.existsSync(pkgPath) ? readJson(pkgPath) : null; + + const deps = { + ...((pkg?.dependencies as Record | undefined) ?? {}), + ...((pkg?.devDependencies as Record | undefined) ?? {}), + }; + + const rnVersion = deps['react-native'] ?? null; + const expoVersion = deps['expo'] ?? null; + const hasIosDir = fs.existsSync(path.join(projectRoot, 'ios')); + const hasAndroidDir = fs.existsSync(path.join(projectRoot, 'android')); + const hasPlaywright = + fs.existsSync(path.join(projectRoot, 'playwright.config.ts')) || + fs.existsSync(path.join(projectRoot, 'playwright.config.js')) || + fs.existsSync(path.join(projectRoot, 'playwright.config.mjs')); + + let projectType: WorkspaceInfo['projectType'] = 'unknown'; + if (expoVersion) projectType = 'expo'; + else if (rnVersion) projectType = 'rn'; + else if (hasIosDir && hasAndroidDir) projectType = 'mixed'; + else if (hasIosDir) projectType = 'ios'; + else if (hasAndroidDir) projectType = 'android'; + else if (hasPlaywright) projectType = 'web'; + + const ios = detectIosBundleId(projectRoot); + const android = detectAndroidBundleId(projectRoot); + + const devices = await discoverBootedDevices().catch(() => []); + let metroPort: number | null = null; + for (const d of devices) { + if (d.platform !== 'ios' && d.platform !== 'tvos' && d.platform !== 'android') continue; + const port = await discoverMetroPortForDevice(d.platform, d.id).catch(() => null); + if (port) { + metroPort = port; + break; + } + } + + const info: WorkspaceInfo = { + projectRoot, + projectType, + reactNativeVersion: rnVersion, + bundleIds: { ios: ios.id, android: android.id }, + bundleNames: { ios: ios.name, android: android.name }, + hasIosDir, + hasAndroidDir, + hasPlaywrightConfig: hasPlaywright, + configuredDevices: devices.map((d) => ({ id: d.id, name: d.name, platform: d.platform })), + metroPort, + currentSession: process.env.CONDUCTOR_DEVICE ?? 'default', + }; + + if (opts.json) { + printData(info, opts); + } else { + const lines = [ + `projectRoot: ${info.projectRoot}`, + `projectType: ${info.projectType}`, + `reactNativeVersion: ${info.reactNativeVersion ?? '(none)'}`, + `iOS bundle id: ${info.bundleIds.ios ?? '(none)'}`, + `Android bundle id: ${info.bundleIds.android ?? '(none)'}`, + `ios/ dir: ${info.hasIosDir}`, + `android/ dir: ${info.hasAndroidDir}`, + `playwright config: ${info.hasPlaywrightConfig}`, + `metroPort: ${info.metroPort ?? '(not running)'}`, + `booted devices: ${info.configuredDevices.length}`, + ...info.configuredDevices.map((d) => ` - ${d.id} ${d.name} (${d.platform})`), + ]; + console.log(lines.join('\n')); + } + return 0; +} + +export async function workspaceCmd(sub: string, opts: OutputOptions = {}): Promise { + if (sub === 'info' || sub === '') return workspaceInfo(opts); + printError(`Unknown workspace subcommand: ${sub}`, opts); + return 1; +} From 330b508ebd533f32baafb14ce1f601879355ea62 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:49:33 +0200 Subject: [PATCH 08/14] feat(cli): flow record (start / echo / status / finish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command-level recorder — Conductor drivers don't expose an input event channel, so we record the commands the agent issues rather than user gestures. This is the same trade-off Argent makes with their flow-add-step meta-tool; we go a step further with an auto-append hook in the dispatcher (wired in a later commit) so the agent doesn't need to wrap each step explicitly. - flow-recorder.ts: session-scoped active recording path, append helpers, commandToYamlStep mapping covering the action-command subset that faithfully replays through flow-runner.ts. - flow-record.ts: the start/echo/status/finish subcommand surface. Output flow YAML round-trips through the existing run-flow command. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/flow-record.ts | 63 ++++++++++ packages/cli/src/drivers/flow-recorder.ts | 141 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/cli/src/commands/flow-record.ts create mode 100644 packages/cli/src/drivers/flow-recorder.ts diff --git a/packages/cli/src/commands/flow-record.ts b/packages/cli/src/commands/flow-record.ts new file mode 100644 index 0000000..340fc42 --- /dev/null +++ b/packages/cli/src/commands/flow-record.ts @@ -0,0 +1,63 @@ +export const HELP = ` flow record start [--out path] Start a YAML flow recording for this session + flow record finish Close the active recording, print the file path + flow record echo Insert a console.log step + flow record status Show the active recording path (if any)`; + +import { printSuccess, printError, printData, OutputOptions } from '../output.js'; +import { + startRecording, + finishRecording, + getActiveRecording, + appendEcho, +} from '../drivers/flow-recorder.js'; +import { getSession } from '../session.js'; + +export async function flowRecord( + sub: string, + rest: string[], + opts: OutputOptions, + sessionName: string, + argv: Record +): Promise { + if (sub === 'start') { + const out = argv['out'] as string | undefined; + const session = await getSession(sessionName); + const target = await startRecording(sessionName, out, session.appId); + if (opts.json) printData({ recordingPath: target }, opts); + else printSuccess(`flow record start — writing to ${target}`, opts); + return 0; + } + + if (sub === 'finish') { + const out = await finishRecording(sessionName); + if (!out) { + printError('flow record finish — no active recording for this session', opts); + return 1; + } + if (opts.json) printData({ recordingPath: out }, opts); + else printSuccess(`flow record finish — closed ${out}`, opts); + return 0; + } + + if (sub === 'echo') { + const active = await getActiveRecording(sessionName); + if (!active) { + printError('flow record echo — no active recording (run `flow record start` first)', opts); + return 1; + } + appendEcho(active, rest.join(' ')); + if (opts.json) printData({ ok: true }, opts); + else printSuccess('flow record echo — appended', opts); + return 0; + } + + if (sub === 'status') { + const active = await getActiveRecording(sessionName); + if (opts.json) printData({ active }, opts); + else console.log(active ? `recording: ${active}` : 'no active recording'); + return 0; + } + + printError('Usage: conductor flow record ', opts); + return 1; +} diff --git a/packages/cli/src/drivers/flow-recorder.ts b/packages/cli/src/drivers/flow-recorder.ts new file mode 100644 index 0000000..7672d22 --- /dev/null +++ b/packages/cli/src/drivers/flow-recorder.ts @@ -0,0 +1,141 @@ +/** + * Active flow recording. When a path is registered for a session, successful + * device-action commands append themselves to the file as YAML steps. + * + * This is a *command-level* recorder — Conductor's drivers don't expose an + * input event channel (they receive commands, not user gestures), so we record + * the commands the agent issues rather than user-driven taps. Pair with + * `flow record start` to begin and `flow record finish` to close out. + */ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { updateSession, getSession, type Session } from '../session.js'; + +interface SessionWithRecording extends Session { + recordingPath?: string; +} + +const FLOWS_DIR = path.join(os.homedir(), '.conductor', 'recordings'); + +export function defaultRecordingPath(sessionName: string): string { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join(FLOWS_DIR, `${sessionName}-${ts}.yaml`); +} + +export async function startRecording( + sessionName: string, + out: string | undefined, + appId?: string +): Promise { + const target = out ? path.resolve(out) : defaultRecordingPath(sessionName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const header = + (appId ? `appId: ${appId}\n` : `# appId: \n`) + + `---\n# Recording started ${new Date().toISOString()}\n`; + fs.writeFileSync(target, header, 'utf-8'); + await updateSession({ recordingPath: target } as Partial, sessionName); + return target; +} + +export async function finishRecording(sessionName: string): Promise { + const session = (await getSession(sessionName)) as SessionWithRecording; + if (!session.recordingPath) return null; + const out = session.recordingPath; + fs.appendFileSync(out, `# Recording finished ${new Date().toISOString()}\n`); + delete session.recordingPath; + await updateSession(session as Partial, sessionName); + return out; +} + +export async function getActiveRecording(sessionName: string): Promise { + const session = (await getSession(sessionName)) as SessionWithRecording; + return session.recordingPath ?? null; +} + +export function appendStep(filePath: string, yamlStep: string): void { + fs.appendFileSync(filePath, yamlStep.endsWith('\n') ? yamlStep : yamlStep + '\n', 'utf-8'); +} + +export function appendEcho(filePath: string, text: string): void { + fs.appendFileSync( + filePath, + `- runScript: |\n console.log(${JSON.stringify(text)})\n`, + 'utf-8' + ); +} + +/** + * Map a conductor command + args into one or more Maestro-flavoured YAML + * steps. Returns null for commands that should not be recorded (lifecycle, + * inspection, status). The mapping is intentionally narrow — when we cannot + * faithfully replay something, we omit it rather than emit a broken step. + */ +export function commandToYamlStep( + cmd: string, + rest: string[], + argv: Record +): string | null { + switch (cmd) { + case 'launch-app': { + const appId = rest[0]; + if (!appId) return null; + const lines = [`- launchApp:`, ` appId: ${appId}`]; + if (argv['clear-state']) lines.push(` clearState: true`); + return lines.join('\n'); + } + case 'stop-app': + return rest[0] ? `- stopApp: ${rest[0]}` : `- stopApp`; + case 'clear-state': + return rest[0] ? `- clearState:\n appId: ${rest[0]}` : `- clearState`; + case 'tap-on': { + const text = rest.join(' ').trim(); + const id = argv['id'] as string | undefined; + const t = argv['text'] as string | undefined; + if (id) return `- tapOn:\n id: ${JSON.stringify(id)}`; + if (t) return `- tapOn:\n text: ${JSON.stringify(t)}`; + if (text) return `- tapOn: ${JSON.stringify(text)}`; + return null; + } + case 'input-text': + return `- inputText: ${JSON.stringify(rest.join(' '))}`; + case 'erase-text': { + const n = rest[0] ?? argv['characters'] ?? '50'; + return `- eraseText: ${n}`; + } + case 'back': + return `- back`; + case 'hide-keyboard': + return `- hideKeyboard`; + case 'press-key': + return `- pressKey: ${JSON.stringify(rest[0] ?? '')}`; + case 'scroll': + return `- scroll`; + case 'swipe': { + const dir = (argv['direction'] as string | undefined) ?? 'up'; + return `- swipe:\n direction: ${dir}`; + } + case 'open-link': + return `- openLink: ${JSON.stringify(rest[0] ?? '')}`; + case 'set-orientation': + return `- setOrientation: ${rest[0] ?? argv['orientation'] ?? 'portrait'}`; + case 'set-location': { + const lat = argv['lat'] ?? argv['latitude']; + const lng = argv['lng'] ?? argv['longitude']; + if (lat === undefined || lng === undefined) return null; + return `- setLocation:\n latitude: ${lat}\n longitude: ${lng}`; + } + case 'paste': + return `- runScript: |\n // paste — re-record manually if needed`; + case 'assert-visible': { + const text = rest.join(' ').trim(); + return text ? `- assertVisible: ${JSON.stringify(text)}` : null; + } + case 'assert-not-visible': { + const text = rest.join(' ').trim(); + return text ? `- assertNotVisible: ${JSON.stringify(text)}` : null; + } + default: + return null; + } +} From 54819de9316ad10186d84998d56506b98858173b Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:49:43 +0200 Subject: [PATCH 09/14] feat(cli): crash report capture (list / show / tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new capability — Argent's README claims it but ships no dedicated tool. We normalise iOS host-side .ips/.crash files (from ~/Library/Logs/DiagnosticReports/) and Android logcat -b crash blocks into a shared { id, timestamp, app, type, signal, threadName, topFrames, sourceFile, platform } shape so agents don't have to parse platform-specific text. tail() streams new reports via fs.watch + adb logcat. Parser is heuristic — fields are best-effort across iOS versions. dSYM symbolication and a stable Android schema are follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/crashes.ts | 283 +++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 packages/cli/src/commands/crashes.ts diff --git a/packages/cli/src/commands/crashes.ts b/packages/cli/src/commands/crashes.ts new file mode 100644 index 0000000..524c726 --- /dev/null +++ b/packages/cli/src/commands/crashes.ts @@ -0,0 +1,283 @@ +export const HELP = ` crashes list [--app ] [--since ] + List recent crash reports (iOS host + Android logcat) + crashes show Print a specific crash report + crashes tail Stream new crash reports as they appear`; + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawn } from 'child_process'; +import { printError, printData, OutputOptions } from '../output.js'; +import { detectPlatform } from '../drivers/bootstrap.js'; +import { resolveAndroidTool, androidSpawnEnv } from '../android/sdk.js'; + +interface CrashReport { + id: string; + timestamp: string; + app: string | null; + type: 'crash' | 'fault' | 'tombstone' | 'logcat'; + signal: string | null; + threadName: string | null; + topFrames: string[]; + sourceFile: string | null; + platform: 'ios' | 'android'; +} + +const IOS_REPORTS_DIR = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports'); + +function parseSince(s: string | undefined): number { + if (!s) return 0; + const m = s.match(/^(\d+)(s|m|h|d)?$/); + if (!m) return 0; + const n = Number(m[1]); + const unit = m[2] ?? 's'; + const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit] ?? 1000; + return n * mult; +} + +function listIosReports(opts: { app?: string; sinceMs: number }): CrashReport[] { + if (!fs.existsSync(IOS_REPORTS_DIR)) return []; + const now = Date.now(); + const entries = fs.readdirSync(IOS_REPORTS_DIR); + const out: CrashReport[] = []; + for (const file of entries) { + if (!file.endsWith('.ips') && !file.endsWith('.crash')) continue; + const full = path.join(IOS_REPORTS_DIR, file); + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (opts.sinceMs > 0 && now - stat.mtimeMs > opts.sinceMs) continue; + const text = (() => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch { + return ''; + } + })(); + const report = parseIpsReport(file, full, text, stat.mtimeMs); + if (opts.app && report.app && !report.app.includes(opts.app)) continue; + out.push(report); + } + return out.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); +} + +function parseIpsReport( + id: string, + full: string, + text: string, + mtimeMs: number +): CrashReport { + // Newer .ips files are JSON-LD style: first line is summary JSON, then body JSON. + // Older .crash files are plain text. Be defensive. + let app: string | null = null; + let signal: string | null = null; + let threadName: string | null = null; + const topFrames: string[] = []; + let type: CrashReport['type'] = 'crash'; + + try { + const firstNewline = text.indexOf('\n'); + if (firstNewline > 0) { + const summary = JSON.parse(text.slice(0, firstNewline)) as { + app_name?: string; + bundleID?: string; + incident_id?: string; + timestamp?: string; + }; + app = summary.bundleID ?? summary.app_name ?? null; + } + } catch { + // ignore — fall back to text parsing + } + + const procMatch = text.match(/Process:\s+(\S+)/); + if (!app && procMatch) app = procMatch[1]; + const sigMatch = text.match(/Exception Type:\s+(\S+)/); + if (sigMatch) signal = sigMatch[1]; + const threadMatch = text.match(/Thread \d+ (Crashed|name):\s*([^\n]+)/); + if (threadMatch) threadName = threadMatch[2].trim(); + const faultMatch = text.includes('fault'); + if (faultMatch) type = 'fault'; + + const frameLines = text.split('\n'); + for (const line of frameLines) { + if (/^\s*\d+\s+\S+\s+0x[0-9a-f]+/i.test(line)) { + topFrames.push(line.trim()); + if (topFrames.length >= 10) break; + } + } + + return { + id, + timestamp: new Date(mtimeMs).toISOString(), + app, + type, + signal, + threadName, + topFrames, + sourceFile: full, + platform: 'ios', + }; +} + +async function listAndroidReports( + deviceId: string, + opts: { app?: string; sinceMs: number } +): Promise { + const adb = resolveAndroidTool('adb'); + const env = androidSpawnEnv(); + const sinceArg = opts.sinceMs > 0 ? ['-T', String(Math.floor((Date.now() - opts.sinceMs) / 1000))] : []; + const output: string = await new Promise((resolve) => { + const proc = spawn(adb, ['-s', deviceId, 'logcat', '-d', '-b', 'crash', ...sinceArg], { + stdio: ['ignore', 'pipe', 'ignore'], + env, + }); + let buf = ''; + proc.stdout.on('data', (c: Buffer) => { + buf += c.toString(); + }); + proc.on('close', () => resolve(buf)); + proc.on('error', () => resolve('')); + }); + + const reports: CrashReport[] = []; + const blocks = output.split(/\n(?=\d{2}-\d{2} \d{2}:\d{2}:\d{2})/); + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (!/FATAL EXCEPTION|AndroidRuntime|tombstone/i.test(block)) continue; + const appMatch = block.match(/Process: ([\w.]+)/); + const app = appMatch ? appMatch[1] : null; + if (opts.app && app && !app.includes(opts.app)) continue; + const tsMatch = block.match(/^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)/); + const sigMatch = block.match(/Signal\s+\d+\s+\(([^)]+)\)/); + const topFrames: string[] = []; + for (const line of block.split('\n')) { + if (/^\s+at\s/.test(line)) { + topFrames.push(line.trim()); + if (topFrames.length >= 10) break; + } + } + reports.push({ + id: `android-${i}-${tsMatch?.[1] ?? Date.now()}`, + timestamp: tsMatch ? new Date().getFullYear() + '-' + tsMatch[1].replace(' ', 'T') : new Date().toISOString(), + app, + type: 'logcat', + signal: sigMatch ? sigMatch[1] : null, + threadName: null, + topFrames, + sourceFile: null, + platform: 'android', + }); + } + return reports; +} + +export interface CrashesListOptions { + app?: string; + since?: string; +} + +export async function crashesList( + opts: OutputOptions, + sessionName: string, + listOpts: CrashesListOptions +): Promise { + const sinceMs = parseSince(listOpts.since); + const platform = sessionName !== 'default' ? await detectPlatform(sessionName).catch(() => null) : null; + + const reports: CrashReport[] = []; + // Always include iOS host-side reports — they aren't device-scoped. + reports.push(...listIosReports({ app: listOpts.app, sinceMs })); + if (platform === 'android' && sessionName !== 'default') { + reports.push(...(await listAndroidReports(sessionName, { app: listOpts.app, sinceMs }))); + } + + if (opts.json) { + printData({ count: reports.length, reports }, opts); + } else { + if (reports.length === 0) console.log('No crash reports found.'); + for (const r of reports) { + console.log( + `${r.timestamp} ${r.platform} ${r.type} ${r.app ?? '?'} ${r.signal ?? '-'} ${r.id}` + ); + } + } + return 0; +} + +export async function crashesShow( + id: string, + opts: OutputOptions +): Promise { + if (!id) { + printError('crashes show requires an ', opts); + return 1; + } + // For iOS, id is the file name in DiagnosticReports. + const ios = path.join(IOS_REPORTS_DIR, id); + if (fs.existsSync(ios)) { + const text = fs.readFileSync(ios, 'utf-8'); + if (opts.json) printData({ id, source: ios, body: text }, opts); + else console.log(text); + return 0; + } + printError(`crashes show — no report found for "${id}"`, opts); + return 1; +} + +export async function crashesTail(opts: OutputOptions, sessionName: string): Promise { + console.log('Watching for new crash reports… (Ctrl+C to stop)'); + // iOS host directory watcher + let lastSeen = Date.now(); + if (fs.existsSync(IOS_REPORTS_DIR)) { + fs.watch(IOS_REPORTS_DIR, (event, file) => { + if (!file) return; + const full = path.join(IOS_REPORTS_DIR, file as string); + try { + const stat = fs.statSync(full); + if (stat.mtimeMs <= lastSeen) return; + lastSeen = stat.mtimeMs; + const text = fs.readFileSync(full, 'utf-8'); + const report = parseIpsReport(file as string, full, text, stat.mtimeMs); + if (opts.json) printData(report, opts); + else + console.log( + `${report.timestamp} ios ${report.type} ${report.app ?? '?'} ${report.signal ?? '-'} ${report.id}` + ); + } catch { + // ignore + } + }); + } + + // Android: spawn `adb logcat -b crash` streaming + if (sessionName !== 'default') { + const platform = await detectPlatform(sessionName).catch(() => null); + if (platform === 'android') { + const adb = resolveAndroidTool('adb'); + const proc = spawn(adb, ['-s', sessionName, 'logcat', '-b', 'crash'], { + stdio: ['ignore', 'pipe', 'ignore'], + env: androidSpawnEnv(), + }); + let buf = ''; + proc.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString(); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + if (/FATAL EXCEPTION|AndroidRuntime|tombstone/.test(line)) { + if (opts.json) printData({ platform: 'android', line }, opts); + else console.log(`android ${line}`); + } + } + }); + } + } + + // Keep alive + await new Promise(() => {}); + return 0; +} From deb734b4dc37added555bf193b51ef87f79da025 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:49:59 +0200 Subject: [PATCH 10/14] =?UTF-8?q?feat(cli):=20experimental=20=E2=80=94=20R?= =?UTF-8?q?N=20debugger,=20network,=20profiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three command groups sharing the MetroCdpClient and an Argent-style script library. Marked experimental because they depend on React Native runtime internals (fiber shape, UIManager / nativeFabricUIManager, renderer.rendererConfig) that may drift across RN versions. - metro-scripts.ts: makeComponentTreeScript and makeInspectElementScript. component-tree: detects Fabric vs Paper, finds UIManager via __r, batch-measures rects, filters a curated SKIP set (View, RNSScreen, NavigationContent, Provider wrappers, etc.). Returns via Runtime.addBinding. inspect-element: uses React's own renderer.rendererConfig. getInspectorDataForViewAtPoint, walks up via .return, resolves source from _debugStack / _debugSource. - debug.ts: status / evaluate / component-tree / inspect-element / log-registry / reload subcommands over Metro CDP. - network.ts: idempotent fetch + XHR shim injected via Runtime.evaluate, read back through a ring buffer. `network request` issues a fetch from the app's network context. - profile.ts: cpu (xctrace / simpleperf), memory polling, react commit profiler via __REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/debug.ts | 312 +++++++++++++++++ packages/cli/src/commands/network.ts | 244 +++++++++++++ packages/cli/src/commands/profile.ts | 351 +++++++++++++++++++ packages/cli/src/drivers/metro-scripts.ts | 399 ++++++++++++++++++++++ 4 files changed, 1306 insertions(+) create mode 100644 packages/cli/src/commands/debug.ts create mode 100644 packages/cli/src/commands/network.ts create mode 100644 packages/cli/src/commands/profile.ts create mode 100644 packages/cli/src/drivers/metro-scripts.ts diff --git a/packages/cli/src/commands/debug.ts b/packages/cli/src/commands/debug.ts new file mode 100644 index 0000000..851bb53 --- /dev/null +++ b/packages/cli/src/commands/debug.ts @@ -0,0 +1,312 @@ +export const HELP = ` debug status [--port N] Show RN debugger connection info + debug evaluate [--port N] Run JS in the app runtime (Hermes/Fusebox) + debug component-tree [--port N] Print the React component tree (on-screen) + debug inspect-element Print the React component at a screen point + debug log-registry [--source metro] Summarize recent Metro/Hermes console logs`; + +import crypto from 'crypto'; +import { printError, printData, OutputOptions } from '../output.js'; +import { MetroCdpClient, cdpCall } from '../drivers/metro-cdp.js'; +import { detectPlatform } from '../drivers/bootstrap.js'; +import { fetchTargets } from '../drivers/log-sources/metro.js'; +import { makeComponentTreeScript, makeInspectElementScript } from '../drivers/metro-scripts.js'; +import { logs as logsCmd } from './logs.js'; + +function newRequestId(): string { + return crypto.randomBytes(6).toString('hex'); +} + +export interface DebugOptions { + port?: number; + targetIndex?: number; +} + +function resolveSession(sessionName: string): { + deviceId?: string; + platformPromise: Promise; +} { + if (!sessionName || sessionName === 'default') { + return { deviceId: undefined, platformPromise: Promise.resolve(undefined) }; + } + return { + deviceId: sessionName, + platformPromise: detectPlatform(sessionName).catch(() => undefined), + }; +} + +export async function debugStatus( + opts: OutputOptions, + sessionName: string, + debugOpts: DebugOptions +): Promise { + const port = debugOpts.port ?? 8081; + try { + const targets = await fetchTargets(port, 'localhost'); + const { deviceId, platformPromise } = resolveSession(sessionName); + const platform = await platformPromise; + + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex }); + await client.enableDomain('Runtime'); + await client.enableDomain('Debugger'); + + // Give a beat for Debugger.scriptParsed events to flow in before reporting count. + await new Promise((r) => setTimeout(r, 300)); + + const info = { + port, + host: 'localhost', + deviceId: deviceId ?? null, + platform: platform ?? null, + connected: client.isConnected(), + enabledDomains: [...client.getEnabledDomains()], + loadedScripts: client.getLoadedScripts().size, + targets: targets.map((t) => ({ + title: t.title ?? null, + deviceName: t.deviceName ?? null, + appId: t.appId ?? null, + id: t.id ?? null, + })), + }; + client.close(); + if (opts.json) printData(info, opts); + else { + console.log( + `port: ${info.port}\n` + + `deviceId: ${info.deviceId ?? '(none)'}\n` + + `platform: ${info.platform ?? '(none)'}\n` + + `connected: ${info.connected}\n` + + `enabledDomains: ${info.enabledDomains.join(', ')}\n` + + `loadedScripts: ${info.loadedScripts}\n` + + `targets (${info.targets.length}):\n` + + info.targets + .map((t, i) => ` ${i}: ${t.title ?? '(no title)'} device=${t.deviceName}`) + .join('\n') + ); + } + return 0; + } catch (err) { + printError(`debug status — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +export async function debugEvaluate( + expr: string, + opts: OutputOptions, + sessionName: string, + debugOpts: DebugOptions +): Promise { + if (!expr) { + printError('debug evaluate requires a JS expression', opts); + return 1; + } + const port = debugOpts.port ?? 8081; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex }); + const value = await client.evaluate(expr); + client.close(); + if (opts.json) printData({ result: value }, opts); + else console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2)); + return 0; + } catch (err) { + printError(`debug evaluate — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +interface ComponentNode { + name: string; + depth: number; + testID: string | null; + label: string | null; + text: string | null; + rect: Rect | null; +} + +interface ComponentTreePayload { + requestId: string; + screenW?: number; + screenH?: number; + fabric?: boolean; + components?: ComponentNode[]; + error?: string; +} + +export async function debugComponentTree( + opts: OutputOptions, + sessionName: string, + debugOpts: DebugOptions +): Promise { + const port = debugOpts.port ?? 8081; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex }); + const awaitCallback = await client.installCallbackBinding(); + const requestId = newRequestId(); + const pending = awaitCallback(requestId, 15_000); + await client.evaluate(makeComponentTreeScript(requestId), false); + const result = (await pending) as ComponentTreePayload; + client.close(); + + if (result.error) { + printError(`debug component-tree — ${result.error}`, opts); + return 1; + } + const components = result.components ?? []; + // Filter to on-screen components: rect present and inside the screen. + const screenW = result.screenW ?? 0; + const screenH = result.screenH ?? 0; + const onScreen = components.filter((c) => { + if (!c.rect) return false; + const { x, y, w, h } = c.rect; + if (w <= 0 || h <= 0) return false; + if (screenW > 0 && (x + w < 0 || x > screenW)) return false; + if (screenH > 0 && (y + h < 0 || y > screenH)) return false; + return true; + }); + + if (opts.json) { + printData( + { + count: onScreen.length, + total: components.length, + fabric: result.fabric ?? false, + screenW, + screenH, + components: onScreen, + }, + opts + ); + } else { + for (const c of onScreen) { + const parts = [' '.repeat(c.depth) + c.name]; + if (c.testID) parts.push(`testID=${c.testID}`); + if (c.label) parts.push(`label=${JSON.stringify(c.label)}`); + if (c.text) parts.push(`text=${JSON.stringify(c.text.slice(0, 40))}`); + if (c.rect) { + const r = c.rect; + parts.push(`[${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}]`); + } + console.log(parts.join(' ')); + } + console.log( + `\n${onScreen.length} on-screen / ${components.length} total (${result.fabric ? 'Fabric' : 'Paper'})` + ); + } + return 0; + } catch (err) { + printError(`debug component-tree — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +interface InspectFrame { + fn: string; + file: string; + line: number; + col: number; + original?: boolean; +} + +interface InspectItem { + name: string; + depth: number; + frame: InspectFrame | null; +} + +interface InspectPayload { + requestId: string; + x?: number; + y?: number; + items?: InspectItem[]; + error?: string; +} + +export async function debugInspectElement( + at: string, + opts: OutputOptions, + sessionName: string, + debugOpts: DebugOptions +): Promise { + const m = at.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/); + if (!m) { + printError('debug inspect-element expects ","', opts); + return 1; + } + const x = Number(m[1]); + const y = Number(m[2]); + const port = debugOpts.port ?? 8081; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex }); + const awaitCallback = await client.installCallbackBinding(); + const requestId = newRequestId(); + const pending = awaitCallback(requestId, 8_000); + await client.evaluate(makeInspectElementScript(x, y, requestId), false); + const result = (await pending) as InspectPayload; + client.close(); + + if (result.error) { + printError(`debug inspect-element — ${result.error}`, opts); + return 1; + } + if (opts.json) printData(result, opts); + else { + console.log(`Components at (${x}, ${y}) — closest first:`); + for (const item of result.items ?? []) { + const src = item.frame + ? ` (${item.frame.file}:${item.frame.line}${item.frame.original ? ' [original]' : ''})` + : ''; + console.log(`${' '.repeat(item.depth)}${item.name}${src}`); + } + } + return 0; + } catch (err) { + printError(`debug inspect-element — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +export async function debugLogRegistry(opts: OutputOptions, sessionName: string): Promise { + // Delegate to the existing `logs` command in summary mode (--list). + return logsCmd(opts, sessionName, { source: 'metro', list: true }); +} + +export async function debugReload( + opts: OutputOptions, + sessionName: string, + debugOpts: DebugOptions +): Promise { + const port = debugOpts.port ?? 8081; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + await cdpCall('Page.reload', undefined, { + port, + deviceId, + platform, + targetIndex: debugOpts.targetIndex, + }); + if (opts.json) printData({ reloaded: true, port, method: 'cdp' }, opts); + else console.log(`reloaded port=${port}`); + return 0; + } catch (err) { + printError(`debug reload — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} diff --git a/packages/cli/src/commands/network.ts b/packages/cli/src/commands/network.ts new file mode 100644 index 0000000..a571fb1 --- /dev/null +++ b/packages/cli/src/commands/network.ts @@ -0,0 +1,244 @@ +export const HELP = ` network logs [--port N] [--limit N] Read recent HTTP traffic (installs a fetch/XHR shim once) + network request [--method M] [--body STR] [--header K=V] [--port N] + Issue an HTTP request from the app's context`; + +import { printError, printData, OutputOptions } from '../output.js'; +import { MetroCdpClient } from '../drivers/metro-cdp.js'; +import { detectPlatform } from '../drivers/bootstrap.js'; + +export interface NetworkOptions { + port?: number; + targetIndex?: number; + limit?: number; +} + +export interface NetworkRequestOptions extends NetworkOptions { + method?: string; + body?: string; + headers?: string[]; +} + +const INSTALL_SHIM_SCRIPT = ` +(() => { + if (globalThis.__CONDUCTOR_NET__ && globalThis.__CONDUCTOR_NET__.installed) { + return { installed: true, already: true }; + } + const ring = []; + const MAX = 200; + function push(entry) { + ring.push(entry); + if (ring.length > MAX) ring.shift(); + } + globalThis.__CONDUCTOR_NET__ = { + installed: true, + entries: ring, + clear: () => { ring.length = 0; }, + }; + + const realFetch = globalThis.fetch; + if (typeof realFetch === 'function') { + globalThis.fetch = function patchedFetch(input, init) { + const url = typeof input === 'string' ? input : (input && input.url) || String(input); + const method = (init && init.method) || (input && input.method) || 'GET'; + const start = Date.now(); + const id = Math.random().toString(36).slice(2, 10); + const entry = { id, kind: 'fetch', method, url, status: null, durationMs: null, error: null, start }; + push(entry); + let p; + try { p = realFetch.apply(this, arguments); } catch (e) { + entry.error = String(e && e.message || e); + entry.durationMs = Date.now() - start; + throw e; + } + return p.then(res => { + entry.status = res.status; + entry.durationMs = Date.now() - start; + return res; + }, err => { + entry.error = String(err && err.message || err); + entry.durationMs = Date.now() - start; + throw err; + }); + }; + } + + const RealXHR = globalThis.XMLHttpRequest; + if (RealXHR) { + const origOpen = RealXHR.prototype.open; + const origSend = RealXHR.prototype.send; + RealXHR.prototype.open = function open(method, url) { + this.__c_meta = { id: Math.random().toString(36).slice(2, 10), kind: 'xhr', method, url, status: null, durationMs: null, error: null, start: 0 }; + return origOpen.apply(this, arguments); + }; + RealXHR.prototype.send = function send() { + const meta = this.__c_meta; + if (meta) { + meta.start = Date.now(); + push(meta); + this.addEventListener('loadend', () => { + meta.status = this.status; + meta.durationMs = Date.now() - meta.start; + }); + this.addEventListener('error', () => { + meta.error = 'xhr error'; + meta.durationMs = Date.now() - meta.start; + }); + } + return origSend.apply(this, arguments); + }; + } + return { installed: true, already: false }; +})() +`; + +const READ_SCRIPT = (limit: number) => ` +(() => { + const tap = globalThis.__CONDUCTOR_NET__; + if (!tap) return { installed: false, entries: [] }; + const entries = tap.entries.slice(-${limit}); + return { installed: true, count: entries.length, entries }; +})() +`; + +interface ShimResult { + installed: boolean; + already?: boolean; +} + +interface NetEntry { + id: string; + kind: 'fetch' | 'xhr'; + method: string; + url: string; + status: number | null; + durationMs: number | null; + error: string | null; + start: number; +} + +function resolveSession(sessionName: string): { + deviceId?: string; + platformPromise: Promise; +} { + if (!sessionName || sessionName === 'default') { + return { deviceId: undefined, platformPromise: Promise.resolve(undefined) }; + } + return { + deviceId: sessionName, + platformPromise: detectPlatform(sessionName).catch(() => undefined), + }; +} + +export async function networkLogs( + opts: OutputOptions, + sessionName: string, + netOpts: NetworkOptions +): Promise { + const port = netOpts.port ?? 8081; + const limit = netOpts.limit ?? 50; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: netOpts.targetIndex }); + await client.evaluate(INSTALL_SHIM_SCRIPT); + const result = await client.evaluate<{ + installed: boolean; + entries?: NetEntry[]; + count?: number; + }>(READ_SCRIPT(limit)); + client.close(); + const entries = result.entries ?? []; + if (opts.json) { + printData({ installed: result.installed, count: entries.length, entries }, opts); + } else { + if (entries.length === 0) { + console.log( + 'No network entries captured yet. The shim is installed; reload the app and try again.' + ); + } + for (const e of entries) { + const ts = new Date(e.start).toISOString().slice(11, 23); + const status = e.error ? `ERR ${e.error}` : e.status !== null ? String(e.status) : '...'; + const dur = e.durationMs !== null ? `${e.durationMs}ms` : '-'; + console.log( + `${ts} ${status.padEnd(6)} ${e.method.padEnd(6)} ${e.url} (${dur}, ${e.kind})` + ); + } + } + return 0; + } catch (err) { + printError(`network logs — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +export async function networkRequest( + url: string, + opts: OutputOptions, + sessionName: string, + reqOpts: NetworkRequestOptions +): Promise { + if (!url) { + printError('network request requires a URL', opts); + return 1; + } + const method = (reqOpts.method ?? 'GET').toUpperCase(); + const headers: Record = {}; + for (const h of reqOpts.headers ?? []) { + const idx = h.indexOf('='); + if (idx > 0) headers[h.slice(0, idx)] = h.slice(idx + 1); + } + const body = reqOpts.body; + const init = JSON.stringify({ + method, + headers, + ...(body !== undefined ? { body } : {}), + }); + + const script = ` +(async () => { + try { + const res = await fetch(${JSON.stringify(url)}, ${init}); + const text = await res.text(); + const hdrs = {}; + res.headers.forEach((v, k) => { hdrs[k] = v; }); + return { ok: res.ok, status: res.status, headers: hdrs, body: text }; + } catch (e) { + return { ok: false, error: String(e && e.message || e) }; + } +})() +`; + + const port = reqOpts.port ?? 8081; + const { deviceId, platformPromise } = resolveSession(sessionName); + try { + const platform = await platformPromise; + const client = new MetroCdpClient(); + await client.connect({ port, deviceId, platform, targetIndex: reqOpts.targetIndex }); + const result = await client.evaluate<{ + ok: boolean; + status?: number; + headers?: Record; + body?: string; + error?: string; + }>(script); + client.close(); + if (opts.json) printData(result, opts); + else { + if (result.error) console.error(`error: ${result.error}`); + console.log(`status: ${result.status ?? 'n/a'}`); + if (result.headers) { + for (const [k, v] of Object.entries(result.headers)) console.log(`${k}: ${v}`); + } + if (result.body !== undefined) { + console.log(''); + console.log(result.body); + } + } + return result.ok ? 0 : 1; + } catch (err) { + printError(`network request — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} diff --git a/packages/cli/src/commands/profile.ts b/packages/cli/src/commands/profile.ts new file mode 100644 index 0000000..aeaffc1 --- /dev/null +++ b/packages/cli/src/commands/profile.ts @@ -0,0 +1,351 @@ +export const HELP = ` profile cpu --duration [--out ] + Record a CPU trace (iOS: xctrace, Android: simpleperf) + profile memory --track [--interval ] [] + Sample memory for N seconds, report deltas + profile react start Install a React commit-profiler hook in the JS runtime + profile react stop [--top N] Stop and summarise captured React commits`; + +import { spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { printError, printData, printSuccess, OutputOptions } from '../output.js'; +import { detectPlatform } from '../drivers/bootstrap.js'; +import { resolveAndroidTool, androidSpawnEnv } from '../android/sdk.js'; +import { memory } from './memory.js'; +import { MetroCdpClient } from '../drivers/metro-cdp.js'; + +export interface ProfileCpuOptions { + durationSec: number; + out?: string; + appId?: string; +} + +function defaultTracePath(prefix: string, ext: string): string { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join(os.tmpdir(), `${prefix}-${ts}.${ext}`); +} + +async function recordIosCpu( + deviceId: string, + appId: string | undefined, + durationSec: number, + out: string +): Promise { + const args = [ + 'xctrace', + 'record', + '--template', + 'Time Profiler', + '--device', + deviceId, + '--time-limit', + `${durationSec}s`, + '--output', + out, + ]; + if (appId) args.push('--attach', appId); + await new Promise((resolve, reject) => { + const proc = spawn('xcrun', args, { stdio: 'inherit' }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`xctrace exited with code ${code}`)) + ); + proc.on('error', reject); + }); +} + +async function recordAndroidCpu( + deviceId: string, + appId: string | undefined, + durationSec: number, + out: string +): Promise { + const adb = resolveAndroidTool('adb'); + const env = androidSpawnEnv(); + const remote = `/data/local/tmp/conductor-perf-${Date.now()}.data`; + const recordArgs = [ + '-s', + deviceId, + 'shell', + 'simpleperf', + 'record', + '-o', + remote, + '--duration', + String(durationSec), + ]; + if (appId) { + recordArgs.push('--app', appId); + } else { + recordArgs.push('-a'); + } + await new Promise((resolve, reject) => { + const proc = spawn(adb, recordArgs, { stdio: 'inherit', env }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`simpleperf record exited with ${code}`)) + ); + proc.on('error', reject); + }); + await new Promise((resolve, reject) => { + const proc = spawn(adb, ['-s', deviceId, 'pull', remote, out], { stdio: 'inherit', env }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`adb pull exited with ${code}`)) + ); + proc.on('error', reject); + }); + await new Promise((resolve) => { + const proc = spawn(adb, ['-s', deviceId, 'shell', 'rm', remote], { stdio: 'ignore', env }); + proc.on('close', () => resolve()); + proc.on('error', () => resolve()); + }); +} + +export async function profileCpu( + opts: OutputOptions, + sessionName: string, + profileOpts: ProfileCpuOptions +): Promise { + if (sessionName === 'default') { + printError('profile cpu requires a --device', opts); + return 1; + } + const platform = await detectPlatform(sessionName).catch(() => null); + const isIos = platform === 'ios' || platform === 'tvos'; + const out = profileOpts.out ?? defaultTracePath('cpu', isIos ? 'trace' : 'perf.data'); + try { + if (isIos) { + await recordIosCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out); + } else if (platform === 'android') { + await recordAndroidCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out); + } else { + printError(`profile cpu is not supported on platform ${platform ?? '(unknown)'}`, opts); + return 1; + } + if (opts.json) printData({ out, durationSec: profileOpts.durationSec, platform }, opts); + else printSuccess(`profile cpu — recorded ${profileOpts.durationSec}s → ${out}`, opts); + return 0; + } catch (err) { + printError(`profile cpu — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +export interface ProfileMemoryOptions { + trackSec: number; + intervalMs: number; + appId?: string; +} + +export async function profileMemory( + opts: OutputOptions, + sessionName: string, + profileOpts: ProfileMemoryOptions +): Promise { + const samples: Array<{ at: number; sample: string }> = []; + const start = Date.now(); + const end = start + profileOpts.trackSec * 1000; + + while (Date.now() < end) { + const at = Date.now() - start; + // Capture memory output for this sample by intercepting stdout. + const captured = await captureStdout(async () => { + await memory(profileOpts.appId, { json: true }, sessionName, {}); + }); + samples.push({ at, sample: captured }); + if (Date.now() < end) { + await new Promise((r) => setTimeout(r, profileOpts.intervalMs)); + } + } + + const parsed = samples.map((s) => { + try { + return { at: s.at, data: JSON.parse(s.sample) as Record }; + } catch { + return { at: s.at, data: null }; + } + }); + + if (opts.json) { + printData({ samples: parsed, durationMs: Date.now() - start }, opts); + } else { + console.log(`profile memory — ${samples.length} samples over ${profileOpts.trackSec}s`); + for (const p of parsed) { + const summary = + p.data && typeof p.data === 'object' + ? Object.entries(p.data) + .slice(0, 4) + .map(([k, v]) => `${k}=${typeof v === 'object' ? '…' : String(v)}`) + .join(' ') + : '(parse error)'; + console.log(` t+${(p.at / 1000).toFixed(1)}s ${summary}`); + } + } + return 0; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((c: string | Uint8Array) => { + chunks.push(typeof c === 'string' ? c : Buffer.from(c).toString()); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = origWrite; + } + return chunks.join(''); +} + +// ── React profiler ──────────────────────────────────────────────────────────── + +const REACT_PROFILER_INSTALL = ` +(() => { + if (globalThis.__CONDUCTOR_REACT_PROFILER__) { + return { installed: true, already: true }; + } + const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) return { installed: false, error: 'No React DevTools hook (Hermes only?)' }; + const commits = []; + const MAX = 500; + const orig = hook.onCommitFiberRoot; + hook.onCommitFiberRoot = function(rendererID, root, priorityLevel) { + try { + const entry = { at: Date.now(), rendererID, components: [] }; + let node = root.current; + const stack = [{ fiber: node, depth: 0 }]; + let count = 0; + while (stack.length && count < 200) { + const { fiber, depth } = stack.pop(); + if (!fiber) continue; + const dur = fiber.actualDuration ?? 0; + if (dur > 0) { + const name = (fiber.type && (fiber.type.displayName || fiber.type.name)) || (typeof fiber.type === 'string' ? fiber.type : null); + if (name) { + entry.components.push({ name, depth, actualDuration: dur, selfDuration: fiber.selfBaseDuration ?? 0 }); + count++; + } + } + if (fiber.child) stack.push({ fiber: fiber.child, depth: depth + 1 }); + if (fiber.sibling) stack.push({ fiber: fiber.sibling, depth }); + } + commits.push(entry); + if (commits.length > MAX) commits.shift(); + } catch (e) {} + if (typeof orig === 'function') return orig.apply(this, arguments); + }; + globalThis.__CONDUCTOR_REACT_PROFILER__ = { + installed: true, + commits, + uninstall: () => { hook.onCommitFiberRoot = orig; } + }; + return { installed: true, already: false }; +})() +`; + +const REACT_PROFILER_READ = (top: number) => ` +(() => { + const p = globalThis.__CONDUCTOR_REACT_PROFILER__; + if (!p) return { installed: false, commits: [] }; + const commits = p.commits.slice(); + const byName = {}; + for (const c of commits) { + for (const comp of c.components) { + byName[comp.name] = byName[comp.name] ?? { name: comp.name, totalMs: 0, renders: 0 }; + byName[comp.name].totalMs += comp.actualDuration; + byName[comp.name].renders += 1; + } + } + const top = Object.values(byName).sort((a, b) => b.totalMs - a.totalMs).slice(0, ${top}); + return { installed: true, commits, totalCommits: commits.length, top }; +})() +`; + +const REACT_PROFILER_STOP = ` +(() => { + const p = globalThis.__CONDUCTOR_REACT_PROFILER__; + if (!p) return { installed: false }; + if (typeof p.uninstall === 'function') p.uninstall(); + delete globalThis.__CONDUCTOR_REACT_PROFILER__; + return { installed: true, stopped: true }; +})() +`; + +interface ReactProfilerStartResult { + installed: boolean; + already?: boolean; + error?: string; +} + +interface ReactProfilerReadResult { + installed: boolean; + totalCommits?: number; + top?: Array<{ name: string; totalMs: number; renders: number }>; + commits?: Array<{ at: number; components: Array<{ name: string; depth: number; actualDuration: number }> }>; +} + +export async function profileReactStart( + opts: OutputOptions, + sessionName: string, + cdpOpts: { port?: number; targetIndex?: number } +): Promise { + try { + const platform = await detectPlatform(sessionName).catch(() => undefined); + const client = new MetroCdpClient(); + await client.connect({ + port: cdpOpts.port ?? 8081, + deviceId: sessionName !== 'default' ? sessionName : undefined, + platform, + targetIndex: cdpOpts.targetIndex, + }); + const result = await client.evaluate(REACT_PROFILER_INSTALL); + client.close(); + if (!result.installed) { + printError(`profile react start — ${result.error ?? 'install failed'}`, opts); + return 1; + } + if (opts.json) printData(result, opts); + else printSuccess(`profile react start — ${result.already ? 'already installed' : 'installed'}`, opts); + return 0; + } catch (err) { + printError(`profile react start — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} + +export async function profileReactStop( + opts: OutputOptions, + sessionName: string, + cdpOpts: { port?: number; targetIndex?: number }, + top: number +): Promise { + try { + const platform = await detectPlatform(sessionName).catch(() => undefined); + const client = new MetroCdpClient(); + await client.connect({ + port: cdpOpts.port ?? 8081, + deviceId: sessionName !== 'default' ? sessionName : undefined, + platform, + targetIndex: cdpOpts.targetIndex, + }); + const read = await client.evaluate(REACT_PROFILER_READ(top)); + await client.evaluate(REACT_PROFILER_STOP); + client.close(); + if (!read.installed) { + printError('profile react stop — profiler was not installed', opts); + return 1; + } + if (opts.json) printData(read, opts); + else { + console.log(`profile react — ${read.totalCommits ?? 0} commit(s)`); + for (const t of read.top ?? []) { + console.log(` ${t.totalMs.toFixed(1)}ms ${t.renders}x ${t.name}`); + } + } + return 0; + } catch (err) { + printError(`profile react stop — ${err instanceof Error ? err.message : String(err)}`, opts); + return 1; + } +} diff --git a/packages/cli/src/drivers/metro-scripts.ts b/packages/cli/src/drivers/metro-scripts.ts new file mode 100644 index 0000000..3ead1f5 --- /dev/null +++ b/packages/cli/src/drivers/metro-scripts.ts @@ -0,0 +1,399 @@ +/** + * JS payloads injected into the RN runtime via `Runtime.evaluate`. + * + * These scripts mirror the approach in software-mansion/argent: + * - Detect Fabric (`nativeFabricUIManager`) vs Paper (`UIManager` via `__r`). + * - For component-tree: walk the fiber tree, filter wrappers via a SKIP set, + * batch-measure on-screen rects via Paper/Fabric measure APIs, return JSON + * via the `__conductor_callback` binding keyed on `requestId`. + * - For inspect-element: use `renderer.rendererConfig.getInspectorDataForViewAtPoint` + * (React's own inspector) and walk UP via `.return` from `data.closestInstance`. + * + * Both scripts return a small ack value synchronously and post the real result + * asynchronously through the binding — that's why the caller uses + * `MetroCdpClient.installCallbackBinding`. + */ + +/** RN internals + navigation/safe-area wrappers we always strip from the tree. */ +const SKIP_NAMES = [ + 'View', + 'RCTView', + 'RCTText', + 'RCTScrollView', + 'RCTScrollContentView', + 'RCTImageView', + 'RCTSafeAreaView', + 'RCTVirtualText', + 'RCTSinglelineTextInputView', + 'RCTMultilineTextInputView', + 'RNCSafeAreaProvider', + 'RNSScreen', + 'RNSScreenStack', + 'RNSScreenContentWrapper', + 'RNSScreenNavigationContainer', + 'RNSScreenStackHeaderConfig', + 'ScreenStackHeaderConfig', + 'NavigationContent', + 'PreventRemoveProvider', + 'EnsureSingleNavigator', + 'StaticContainer', + 'SceneView', + 'NativeStackView', + 'NativeStackNavigator', + 'DelayedFreeze', + 'Freeze', + 'Suspender', + 'DebugContainer', + 'ScreenContentWrapper', + 'Screen', + 'ScreenStack', + 'ScreenContainer', + 'MaybeScreenContainer', + 'MaybeScreen', + 'FrameSizeProvider', + 'FrameSizeProviderInner', + 'FrameSizeListenerNativeFallback', + 'SafeAreaProviderCompat', + 'SafeAreaProvider', + 'SafeAreaInsetsContext', + 'SafeArea', + 'SafeAreaFrameContext', + 'ErrorOverlay', + 'ErrorToastContainer', + 'PerformanceLoggerContext', + 'AppContainer', + 'RootTagContext', + 'DebuggingOverlay', + 'DebuggingOverlayRegistrySubscription', + 'LogBoxStateSubscription', + '_LogBoxNotificationContainer', + 'LogBoxInspectorContainer', + 'LogBoxInspector', + 'LogBoxInspectorCodeFrame', + 'CellRenderer', + 'VirtualizedListContextProvider', + 'VirtualizedListCellContextProvider', + 'wrapper', + 'Background', + 'Pressable', + 'PlatformPressable', + 'ExpoRoot', + 'ContextNavigator', + 'RootApp', + 'ThemeProvider', + 'StatusBar', + 'ReactNativeProfiler', + 'NavigationRouteContext', + 'BottomTabNavigator', + 'BottomTabView', + 'ImageAnalyticsTagContext', + 'GestureHandlerRootView', + 'GestureDetector', + 'Wrap', + 'NavigationContainerInner', + 'BaseNavigationContainer', + 'PlatformPressableInternal', +]; + +const HARD_SKIP_NAMES = [ + 'BaseTextInput', + 'InternalTextInput', + 'RNTextInputWithRef', + 'RCTSinglelineTextInputView', + 'RCTMultilineTextInputView', +]; + +/** + * Component-tree walker. Returns a script that, when evaluated, returns 'ok' + * synchronously and posts a JSON payload `{ requestId, components, screenW, + * screenH }` via `__conductor_callback(payload)`. + * + * Components carry `{ name, depth, rect, testID, label, text }` for nodes + * that survive SKIP filtering. `rect` is in window coordinates and is + * populated via batched `UIManager.measureInWindow` on Paper, or + * `nativeFabricUIManager.measure` on Fabric. + */ +export function makeComponentTreeScript(requestId: string): string { + const skip = JSON.stringify(SKIP_NAMES); + const hardSkip = JSON.stringify(HARD_SKIP_NAMES); + return `(async function() { + var REQ = ${JSON.stringify(requestId)}; + function done(payload) { + try { globalThis.__conductor_callback(JSON.stringify(Object.assign({ requestId: REQ }, payload))); } catch (e) {} + } + try { + var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) { done({ error: 'No React DevTools hook' }); return 'ok'; } + var roots = hook.getFiberRoots ? hook.getFiberRoots(1) : null; + if (!roots || roots.size === 0) { done({ error: 'No fiber roots' }); return 'ok'; } + var root = Array.from(roots)[0]; + + var useFabric = typeof nativeFabricUIManager !== 'undefined'; + var UIManagerMod = null; + if (!useFabric) { + try { + if (typeof __r === 'function' && typeof __r.getModules === 'function') { + var mods = __r.getModules(); + for (var e of mods) { + if (!e[1].isInitialized) continue; + try { + var m = __r(e[0]); + if (m && m.UIManager) { UIManagerMod = m.UIManager; break; } + } catch (er) {} + } + } + if (!UIManagerMod && typeof __r === 'function') { + for (var i = 0; i < 300; i++) { + try { + var m2 = __r(i); + if (m2 && m2.UIManager) { UIManagerMod = m2.UIManager; break; } + } catch (er) {} + } + } + } catch (er) {} + } + + var SKIP = new Set(${skip}); + var HARD = new Set(${hardSkip}); + function isHardSkip(n) { + if (HARD.has(n)) return true; + if (n.indexOf('AnimatedComponent(') === 0) return true; + if (n.indexOf('Animated(') === 0) return true; + return false; + } + function shouldSkip(n) { + if (isHardSkip(n)) return true; + if (SKIP.has(n)) return true; + if (n.charAt(0) === '_' && n.charAt(1) === '_') return true; + if (n.length > 8 && n.slice(-8) === 'Provider') return true; + if (n.length > 7 && n.slice(-7) === 'Context') return true; + if (n.indexOf('Route(') === 0) return true; + return false; + } + function getName(f) { + var t = f.type; + if (!t) return null; + if (typeof t === 'string') return t; + return t.displayName || t.name || null; + } + function getProps(f) { return f.memoizedProps || null; } + function getHostInfo(f) { + if (typeof f.type !== 'string' || !f.stateNode) return null; + if (useFabric && f.stateNode.node) return { fabric: true, node: f.stateNode.node }; + if (!useFabric) { + if (f.stateNode.canonical && typeof f.stateNode.canonical.nativeTag === 'number') + return { fabric: false, tag: f.stateNode.canonical.nativeTag }; + if (typeof f.stateNode._nativeTag === 'number') + return { fabric: false, tag: f.stateNode._nativeTag }; + } + return null; + } + function findHost(f, d) { + if (!f || d > 15) return null; + var hi = getHostInfo(f); + if (hi) return hi; + return findHost(f.child, d + 1); + } + + // Screen dimensions via Dimensions API. + var screenW = 0, screenH = 0; + try { + if (typeof __r === 'function' && typeof __r.getModules === 'function') { + var mods2 = __r.getModules(); + for (var e2 of mods2) { + if (!e2[1].isInitialized) continue; + try { + var mm = __r(e2[0]); + if (mm && mm.Dimensions && typeof mm.Dimensions.get === 'function') { + var w = mm.Dimensions.get('window'); + if (w && w.width) { screenW = w.width; screenH = w.height; break; } + } + } catch (er) {} + } + } + } catch (er) {} + + // Walk fibers, collect candidates. + var candidates = []; + var stack = [{ f: root.current, d: 0 }]; + while (stack.length && candidates.length < 2000) { + var item = stack.pop(); + var f = item.f, d = item.d; + if (!f) continue; + var name = getName(f); + if (name && !shouldSkip(name)) { + var hi = findHost(f, 0); + var props = getProps(f); + candidates.push({ + name: name, + depth: d, + testID: (props && (props.testID || props['data-testid'])) || null, + label: (props && (props.accessibilityLabel || props['aria-label'])) || null, + text: (props && typeof props.children === 'string') ? props.children : null, + host: hi, + }); + } + if (f.sibling) stack.push({ f: f.sibling, d: d }); + if (f.child) stack.push({ f: f.child, d: d + 1 }); + } + + // Batch measure rects. + function measureFabric(node) { + try { + var r = nativeFabricUIManager.measure(node, function() {}); + if (Array.isArray(r) && r.length >= 6) { + return { x: r[4], y: r[5], w: r[2], h: r[3] }; + } + } catch (e) {} + return null; + } + function measurePaper(tag) { + return new Promise(function(res) { + try { + UIManagerMod.measureInWindow(tag, function(x, y, w, h) { + res({ x: x, y: y, w: w, h: h }); + }); + } catch (e) { res(null); } + }); + } + + var promises = []; + for (var c of candidates) { + if (!c.host) { promises.push(Promise.resolve(null)); continue; } + if (c.host.fabric) { + promises.push(Promise.resolve(measureFabric(c.host.node))); + } else if (UIManagerMod) { + promises.push(measurePaper(c.host.tag)); + } else { + promises.push(Promise.resolve(null)); + } + } + var rects = await Promise.all(promises); + var components = candidates.map(function(c, i) { + return { + name: c.name, + depth: c.depth, + testID: c.testID, + label: c.label, + text: c.text, + rect: rects[i], + }; + }); + + done({ screenW: screenW, screenH: screenH, fabric: useFabric, components: components }); + return 'ok'; + } catch (e) { + done({ error: String((e && e.message) || e) }); + return 'ok'; + } + })();`; +} + +/** + * Inspect-at-point script. Uses React DevTools's own + * `renderer.rendererConfig.getInspectorDataForViewAtPoint(inspectRef, x, y, cb)`, + * which is the authoritative point lookup. Then walks UP via `.return` from + * `data.closestInstance`, preferring `_debugStack` for source resolution and + * falling back to `_debugSource`. + */ +export function makeInspectElementScript(x: number, y: number, requestId: string): string { + return `(function() { + var REQ = ${JSON.stringify(requestId)}; + function done(payload) { + try { globalThis.__conductor_callback(JSON.stringify(Object.assign({ requestId: REQ }, payload))); } catch (e) {} + } + try { + var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) { done({ error: 'No React DevTools hook' }); return 'ok'; } + var renderer = Array.from(hook.renderers.values())[0]; + var roots = hook.getFiberRoots(1); + if (!roots || roots.size === 0) { done({ error: 'No fiber roots' }); return 'ok'; } + var root = Array.from(roots)[0]; + + var useFabric = typeof nativeFabricUIManager !== 'undefined'; + + function findHostFiber(f, d) { + if (!f || d > 30) return null; + if (typeof f.type === 'string' && f.stateNode) { + if (useFabric && f.stateNode.node) return f; + if (!useFabric && f.stateNode.canonical) return f; + } + return findHostFiber(f.child, d + 1) || null; + } + + function getName(f) { + var t = f.type; + if (!t || typeof t === 'string') return null; + if (typeof t === 'function') return t.displayName || t.name || null; + if (typeof t === 'object') { + var inner = t.render || t.type; + if (inner && typeof inner === 'function') return inner.displayName || inner.name || null; + return t.displayName || null; + } + return null; + } + + function parseFrame(stack) { + if (!stack) return null; + var s = typeof stack === 'string' ? stack : (stack.stack || ''); + var lines = s.split('\\n').slice(1).filter(function(l) { return l.trim().indexOf('at ') === 0; }); + var target = lines[1] || lines[0]; + if (!target) return null; + var m = target.trim().match(/at (?:([^\\s(]+) \\()?([^)]+):(\\d+):(\\d+)\\)?/); + return m ? { fn: m[1] || 'anon', file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) } : null; + } + + function getFrame(fiber) { + var frame = parseFrame(fiber._debugStack); + if (frame) return frame; + var ds = fiber._debugSource; + if (ds && ds.fileName) { + return { fn: 'component', file: ds.fileName, line: ds.lineNumber || 0, col: ds.columnNumber || 0, original: true }; + } + return null; + } + + var hostFiber = findHostFiber(root.current.child, 0); + if (!hostFiber) { done({ error: 'no host fiber' }); return 'ok'; } + + var inspectRef; + if (useFabric) { + inspectRef = hostFiber.stateNode; + } else { + inspectRef = hostFiber.stateNode.canonical && hostFiber.stateNode.canonical.publicInstance; + } + if (!inspectRef) { done({ error: 'no inspect ref' }); return 'ok'; } + + var cfg = renderer.rendererConfig; + if (!cfg || typeof cfg.getInspectorDataForViewAtPoint !== 'function') { + done({ error: 'rendererConfig.getInspectorDataForViewAtPoint unavailable' }); + return 'ok'; + } + + cfg.getInspectorDataForViewAtPoint(inspectRef, ${Math.round(x)}, ${Math.round(y)}, function(data) { + try { + var items = []; + var fiber = data.closestInstance; + if (fiber) { + var f = fiber, depth = 0; + while (f && depth < 200) { + var nm = getName(f); + if (nm) items.push({ name: nm, depth: depth, frame: getFrame(f) }); + f = f.return; + depth++; + } + } else if (data.hierarchy && data.hierarchy.length) { + for (var hi of data.hierarchy) items.push({ name: hi.name, depth: 0, frame: null }); + } + done({ x: ${Math.round(x)}, y: ${Math.round(y)}, items: items }); + } catch (e) { + done({ error: String((e && e.message) || e) }); + } + }); + return 'ok'; + } catch (e) { + done({ error: String((e && e.message) || e) }); + return 'ok'; + } + })();`; +} From ea91c2008a04b1af1455c8b047884723e0f4314a Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:50:08 +0200 Subject: [PATCH 11/14] feat(cli): wire new commands into the dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds imports, COMMAND_HELP entries, minimist flag declarations, NO_DEVICE list updates, and case branches for every command introduced in the preceding commits: pinch, rotate-gesture, gesture, clipboard, paste, inspect --at / --tappable, metro, run-sequence, workspace, flow record, crashes, debug, network, profile. Also installs the flow-recording auto-append hook — when a recording is active for the current session, successful action commands are appended to the flow YAML automatically via commandToYamlStep(). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/index.ts | 287 +++++++++++++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 588221f..9f85245 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -36,6 +36,34 @@ import { import { installWebCli, HELP_INSTALL_WEB } from './commands/install.js'; import { devicePool, HELP as devicePoolHelp } from './commands/device-pool.js'; import { runParallel, HELP as runParallelHelp } from './commands/run-parallel.js'; +import { runSequence, HELP as runSequenceHelp } from './commands/run-sequence.js'; +import { pinch, rotateGesture, gesture, HELP as gesturesHelp } from './commands/gestures.js'; +import { workspaceCmd, HELP as workspaceHelp } from './commands/workspace.js'; +import { + debugStatus, + debugEvaluate, + debugComponentTree, + debugInspectElement, + debugLogRegistry, + debugReload, + HELP as debugHelp, +} from './commands/debug.js'; +import { networkLogs, networkRequest, HELP as networkHelp } from './commands/network.js'; +import { flowRecord, HELP as flowRecordHelp } from './commands/flow-record.js'; +import { + profileCpu, + profileMemory, + profileReactStart, + profileReactStop, + HELP as profileHelp, +} from './commands/profile.js'; +import { + crashesList, + crashesShow, + crashesTail, + HELP as crashesHelp, +} from './commands/crashes.js'; +import { getActiveRecording, appendStep, commandToYamlStep } from './drivers/flow-recorder.js'; import { foregroundApp, HELP as foregroundAppHelp } from './commands/foreground-app.js'; import { listApps, HELP as listAppsHelp } from './commands/list-apps.js'; import { copyApp, HELP as copyAppHelp } from './commands/copy-app.js'; @@ -56,6 +84,13 @@ import { stopDevice, HELP as stopDeviceHelp } from './commands/stop-device.js'; import { deleteDevice, HELP as deleteDeviceHelp } from './commands/delete-device.js'; import { logs, HELP as logsHelp } from './commands/logs.js'; import { memory, HELP as memoryHelp } from './commands/memory.js'; +import { metroStop, metroReload, HELP as metroHelp } from './commands/metro.js'; +import { + clipboardRead, + clipboardWrite, + paste, + HELP as clipboardHelp, +} from './commands/clipboard.js'; import { pickDevice } from './device-picker.js'; import { checkForUpdates } from './update-check.js'; import { findPkgRoot } from './pkg-root.js'; @@ -103,8 +138,19 @@ const COMMAND_HELP: Record = { 'daemon-status': daemonStatusHelp, 'device-pool': devicePoolHelp, 'run-parallel': runParallelHelp, + 'run-sequence': runSequenceHelp, + gestures: gesturesHelp, + workspace: workspaceHelp, + debug: debugHelp, + network: networkHelp, + 'flow record': flowRecordHelp, + profile: profileHelp, + crashes: crashesHelp, logs: logsHelp, memory: memoryHelp, + metro: metroHelp, + clipboard: clipboardHelp, + paste: ' paste Trigger OS-level paste (or type clipboard on iOS)', }; const OPTIONS_HELP = `Options: @@ -147,6 +193,7 @@ async function main(): Promise { 'optional', 'benchmark', 'dump', + 'tappable', 'objects', 'heap', 'leaks', @@ -187,6 +234,24 @@ async function main(): Promise { 'vs', 'top', 'filter', + 'port', + 'target', + 'at', + 'file', + 'scale', + 'center', + 'degrees', + 'angle', + 'limit', + 'method', + 'body', + 'header', + 'url', + 'out', + 'track', + 'interval', + 'app', + 'since', ], alias: { h: 'help', v: 'verbose', V: 'version' }, }); @@ -226,6 +291,8 @@ async function main(): Promise { 'copy-app', 'device-pool', 'run-parallel', + 'metro', + 'workspace', // `logs --list` and `logs --source metro` only query Metro on localhost — no device needed // `logs` always needs a device session — Metro discovery is device-scoped. // `daemon-stop --all` stops every daemon — no device needed @@ -529,7 +596,11 @@ async function main(): Promise { } case 'inspect': - exitCode = await inspect(opts, sessionName, { dump: argv['dump'] as boolean }); + exitCode = await inspect(opts, sessionName, { + dump: argv['dump'] as boolean, + at: argv['at'] as string | undefined, + tappableOnly: argv['tappable'] as boolean, + }); break; case 'focused': @@ -619,12 +690,213 @@ async function main(): Promise { break; } + case 'run-sequence': { + const file = (argv['file'] as string | undefined) ?? rest[0]; + exitCode = await runSequence(file, opts, sessionName); + break; + } + case 'run-parallel': { const flowsDir = (argv['flows-dir'] as string | undefined) ?? rest[0] ?? ''; exitCode = await runParallel(flowsDir, opts); break; } + case 'crashes': { + const sub = (rest[0] ?? 'list').toLowerCase(); + if (sub === 'list') { + exitCode = await crashesList(opts, sessionName, { + app: argv['app'] as string | undefined, + since: argv['since'] as string | undefined, + }); + } else if (sub === 'show') { + exitCode = await crashesShow(rest[1] ?? '', opts); + } else if (sub === 'tail') { + exitCode = await crashesTail(opts, sessionName); + } else { + console.error('Usage: conductor crashes '); + exitCode = 1; + } + break; + } + + case 'profile': { + const sub = (rest[0] ?? '').toLowerCase(); + const port = argv['port'] !== undefined ? Number(argv['port']) : undefined; + const targetIndex = argv['target'] !== undefined ? Number(argv['target']) : undefined; + if (sub === 'cpu') { + const durationSec = argv['duration'] !== undefined ? Number(argv['duration']) : 10; + exitCode = await profileCpu(opts, sessionName, { + durationSec, + out: argv['out'] as string | undefined, + appId: rest[1], + }); + } else if (sub === 'memory') { + const trackSec = argv['track'] !== undefined ? Number(argv['track']) : 10; + const intervalMs = argv['interval'] !== undefined ? Number(argv['interval']) : 1000; + exitCode = await profileMemory(opts, sessionName, { + trackSec, + intervalMs, + appId: rest[1], + }); + } else if (sub === 'react') { + const sub2 = (rest[1] ?? '').toLowerCase(); + const top = argv['top'] !== undefined ? Number(argv['top']) : 20; + if (sub2 === 'start') { + exitCode = await profileReactStart(opts, sessionName, { port, targetIndex }); + } else if (sub2 === 'stop') { + exitCode = await profileReactStop(opts, sessionName, { port, targetIndex }, top); + } else { + console.error('Usage: conductor profile react '); + exitCode = 1; + } + } else { + console.error('Usage: conductor profile [args]'); + exitCode = 1; + } + break; + } + + case 'flow': { + const sub1 = (rest[0] ?? '').toLowerCase(); + if (sub1 !== 'record') { + console.error('Usage: conductor flow record '); + exitCode = 1; + break; + } + const sub2 = (rest[1] ?? '').toLowerCase(); + exitCode = await flowRecord( + sub2, + rest.slice(2).map(String), + opts, + sessionName, + argv as unknown as Record + ); + break; + } + + case 'network': { + const sub = (rest[0] ?? '').toLowerCase(); + const port = argv['port'] !== undefined ? Number(argv['port']) : undefined; + const targetIndex = argv['target'] !== undefined ? Number(argv['target']) : undefined; + if (sub === 'logs') { + const limit = argv['limit'] !== undefined ? Number(argv['limit']) : undefined; + exitCode = await networkLogs(opts, sessionName, { port, targetIndex, limit }); + } else if (sub === 'request') { + const url = rest[1] ?? (argv['url'] as string | undefined) ?? ''; + const rawHeaders = argv['header']; + const headers = Array.isArray(rawHeaders) + ? (rawHeaders as string[]) + : rawHeaders + ? [rawHeaders as string] + : []; + exitCode = await networkRequest(url, opts, sessionName, { + port, + targetIndex, + method: argv['method'] as string | undefined, + body: argv['body'] as string | undefined, + headers, + }); + } else { + console.error('Usage: conductor network [args]'); + exitCode = 1; + } + break; + } + + case 'debug': { + const sub = (rest[0] ?? '').toLowerCase(); + const debugOpts = { + port: argv['port'] !== undefined ? Number(argv['port']) : undefined, + targetIndex: argv['target'] !== undefined ? Number(argv['target']) : undefined, + }; + if (sub === 'status') { + exitCode = await debugStatus(opts, sessionName, debugOpts); + } else if (sub === 'evaluate' || sub === 'eval') { + const expr = rest.slice(1).join(' '); + exitCode = await debugEvaluate(expr, opts, sessionName, debugOpts); + } else if (sub === 'component-tree') { + exitCode = await debugComponentTree(opts, sessionName, debugOpts); + } else if (sub === 'inspect-element') { + const at = rest[1] ?? (argv['at'] as string | undefined) ?? ''; + exitCode = await debugInspectElement(at, opts, sessionName, debugOpts); + } else if (sub === 'log-registry') { + exitCode = await debugLogRegistry(opts, sessionName); + } else if (sub === 'reload') { + exitCode = await debugReload(opts, sessionName, debugOpts); + } else { + console.error( + 'Usage: conductor debug ' + ); + exitCode = 1; + } + break; + } + + case 'workspace': { + const sub = (rest[0] ?? 'info').toLowerCase(); + exitCode = await workspaceCmd(sub, opts); + break; + } + + case 'pinch': + exitCode = await pinch(opts, sessionName, { + scale: argv['scale'] !== undefined ? Number(argv['scale']) : undefined, + center: argv['center'] as string | undefined, + duration: argv['duration'] !== undefined ? Number(argv['duration']) : undefined, + angle: argv['angle'] !== undefined ? Number(argv['angle']) : undefined, + }); + break; + + case 'rotate-gesture': + exitCode = await rotateGesture(opts, sessionName, { + degrees: argv['degrees'] !== undefined ? Number(argv['degrees']) : undefined, + center: argv['center'] as string | undefined, + duration: argv['duration'] !== undefined ? Number(argv['duration']) : undefined, + }); + break; + + case 'gesture': { + const file = argv['file'] as string | undefined; + const rawJson = !file ? rest.join(' ') : undefined; + exitCode = await gesture(rawJson, file, opts, sessionName); + break; + } + + case 'clipboard': { + const sub = (rest[0] ?? '').toLowerCase(); + if (sub === 'read') { + exitCode = await clipboardRead(opts, sessionName); + } else if (sub === 'write') { + const text = rest.slice(1).join(' '); + exitCode = await clipboardWrite(text, opts, sessionName); + } else { + console.error('Usage: conductor clipboard [text]'); + exitCode = 1; + } + break; + } + + case 'paste': + exitCode = await paste(opts, sessionName); + break; + + case 'metro': { + const sub = (rest[0] ?? '').toLowerCase(); + const port = argv['port'] !== undefined ? Number(argv['port']) : undefined; + const targetIndex = argv['target'] !== undefined ? Number(argv['target']) : undefined; + const metroSession = (argv['device'] as string | undefined) ?? 'default'; + if (sub === 'stop') { + exitCode = await metroStop(opts, { port }); + } else if (sub === 'reload') { + exitCode = await metroReload(opts, metroSession, { port, targetIndex }); + } else { + console.error('Usage: conductor metro [--port N] [--target N]'); + exitCode = 1; + } + break; + } + default: // Should be unreachable — unknown commands are caught before device resolution. console.error(`Unknown command: ${command}`); @@ -632,6 +904,19 @@ async function main(): Promise { exitCode = 1; } + // Flow recording — append a YAML step for action commands that succeeded. + if (exitCode === 0 && !NO_DEVICE_COMMANDS.has(command) && command !== 'flow') { + try { + const active = await getActiveRecording(sessionName); + if (active) { + const step = commandToYamlStep(command, rest.map(String), argv as Record); + if (step) appendStep(active, step); + } + } catch { + // Recording is best-effort — never fail the command for a bookkeeping issue. + } + } + process.exit(exitCode); } From aad06732ee161466a2baa4e17fdd98914a026a7e Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 18 May 2026 19:50:19 +0200 Subject: [PATCH 12/14] docs: command catalogue updates + experimental page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands.md: new entries under Interaction (pinch, rotate-gesture, gesture, clipboard, paste), Inspection (inspect --at), Flows (run-sequence, flow record). New top-level Workspace, Metro, and Crashes sections. - experimental.md (new): RN debugger, network inspection, profiling. Each group ships with explicit caveats — Hermes-only, Fabric/Paper drift, fetch/XHR shim limits, etc. Stays in this section until a command survives a full RN minor-version cycle without script changes. - marketing-manifest.json: register the new Experimental page under Reference. The houwert.dev sync script picks it up automatically on the next site build. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/commands.md | 44 +++++++++++++++++++++ docs/experimental.md | 76 ++++++++++++++++++++++++++++++++++++ docs/marketing-manifest.json | 6 +++ 3 files changed, 126 insertions(+) create mode 100644 docs/experimental.md diff --git a/docs/commands.md b/docs/commands.md index 622a9c0..1bba586 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -39,6 +39,12 @@ All commands accept `--session ` to scope to a named session and | `scroll` | Scroll a direction or onto an element. | | `scroll-until-visible` | Repeatedly scroll until the target element appears. | | `swipe` | Swipe between two points (or in a cardinal direction). | +| `pinch` | Two-finger pinch. `--scale ` (zoom out <1, zoom in >1), `--center x,y`, `--angle `, `--duration `. | +| `rotate-gesture` | Two-finger rotate. `--degrees `, `--center x,y`, `--duration `. | +| `gesture ` | Play an arbitrary multi-touch path. JSON shape: `[{"steps":[{"x":,"y":,"dt":}]}, ...]`. One path per finger; `dt` is delay since previous step (seconds). Pass `--file path.json` instead of inline JSON. | +| `clipboard read` | Print the iOS simulator clipboard. iOS only — Android has no portable userspace clipboard API. | +| `clipboard write ` | Set the iOS simulator clipboard. | +| `paste` | Type the clipboard contents into the focused field (iOS only). | --- @@ -47,6 +53,7 @@ All commands accept `--session ` to scope to a named session and | Command | What it does | | ----------------- | ------------------------------------------------------------------------------------- | | `inspect` | Dump the live UI hierarchy as JSON. `--dump` prints the raw driver output unmodified. | +| `inspect --at x,y`| Print the topmost view at a screen point. Add `--tappable` to filter to interactive elements. | | `focused` | Print metadata of the focused element. `--poll` keeps printing on change. | | `take-screenshot` | Save a PNG to `--output ` (or stdout if omitted). | | `capture-ui` | Combined screenshot + hierarchy + a11y snapshot — designed to feed into Argus. | @@ -99,6 +106,11 @@ Both accept the same disambiguators as `tap-on`: `--id`, `--text`, | `run-flow` | Run a YAML flow file against the current session's device. | | `run-flow-inline` | Run a YAML flow string passed on the command line (great for one-off agent calls). | | `run-parallel` | Shard a directory of flow files round-robin across every booted device. | +| `run-sequence` | Run a batch of commands serially against one session, stopping on first failure. Reads `{"steps":[{"cmd":"tap-on","args":["Login"]}, ...]}` from `--file path.json` or stdin. | +| `flow record start` | Begin recording subsequent device-action commands into a YAML flow at `--out ` (or `~/.conductor/recordings/`). Any action you run while recording is appended automatically. | +| `flow record echo ` | Insert a `runScript` comment step into the active recording. | +| `flow record status` | Show the active recording path (if any). | +| `flow record finish` | Close the active recording and print the file path. | See [Flows](/conductor/docs/flows) for the YAML format, env var injection, and parallel execution semantics. @@ -116,6 +128,38 @@ Once installed, the same commands above work on `web` "devices" — see --- +## Workspace + +| Command | What it does | +| ----------------- | ---------------------------------------------------------------------------------- | +| `workspace info` | One-shot report of project type (RN / Expo / iOS / Android / Web / mixed), bundle ids, detected `ios/` and `android/` dirs, Metro port, and booted devices. Avoids the agent re-deriving these from `package.json` and `list-devices`. | + +--- + +## Metro + +For React Native projects. + +| Command | What it does | +| --------------- | ---------------------------------------------------------------------------------------- | +| `metro stop` | Stop the Metro bundler process listening on `--port ` (default 8081). Uses `lsof` + `SIGTERM`, escalates to `SIGKILL` after 2s. | +| `metro reload` | Reload the JS bundle without restarting the native process. `Page.reload` over CDP, falls back to `POST /reload`. | + +--- + +## Crashes + +| Command | What it does | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `crashes list` | List recent crash reports. iOS host-side `.ips`/`.crash` files from `~/Library/Logs/DiagnosticReports/`, plus Android `logcat -b crash` for the current device. `--app `, `--since ` (e.g. `2h`, `30m`). | +| `crashes show `| Print a specific iOS crash report by file name. | +| `crashes tail` | Stream new crash reports as they appear. iOS via `fs.watch` on the diagnostic reports directory; Android via `adb logcat -b crash`. | + +Output schema (JSON): `{ id, timestamp, app, type, signal, threadName, topFrames[], sourceFile, platform }`. +The text parser is heuristic — most fields are best-effort across iOS versions; symbolicated frames may not appear without a matching `.dSYM`. + +--- + ## Daemon | Command | What it does | diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 0000000..147035c --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,76 @@ +# Experimental commands + +Conductor ships a set of commands that depend on **React Native runtime internals** +(`__REACT_DEVTOOLS_GLOBAL_HOOK__`, fiber shape, `UIManager` / `nativeFabricUIManager`, +`renderer.rendererConfig`). They work — they're modelled directly on the patterns +used by tools like `react-devtools` — but RN reorganises these internals occasionally, +so the scripts may need maintenance per RN major version. + +If you hit a breakage, the failure mode is usually a clear error (`"No React DevTools hook"`, +`"No fiber roots"`, `"rendererConfig.getInspectorDataForViewAtPoint unavailable"`) and the +underlying Metro / app is unaffected — you can fall back to native inspection. + +All three groups below talk to **Metro's Chrome DevTools Protocol endpoint** (`/json` on +port 8081 by default). Pass `--port ` to point at a different bundler, `--target ` +to pick a specific debugger target when several are connected. + +--- + +## RN debugger + +| Command | What it does | +| -------------------------------- | ---------------------------------------------------------------------------------- | +| `debug status` | Show Metro target list, connection state, loaded scripts, enabled CDP domains. | +| `debug evaluate ` | `Runtime.evaluate` in the app's JS context. Awaits promises. Reads Redux, calls app functions, inspects state. | +| `debug component-tree` | Walk the React fiber tree, batch-measure on-screen rects via `UIManager.measureInWindow` (Paper) or `nativeFabricUIManager.measure` (Fabric). Filters out wrapper noise. | +| `debug inspect-element ` | Use `renderer.rendererConfig.getInspectorDataForViewAtPoint` (React's own inspector) to find the component at a screen point. Walks UP via `.return` and resolves source via `_debugStack` / `_debugSource`. | +| `debug log-registry` | Summary of recent Metro console output — counts by level and clustering. | +| `debug reload` | `Page.reload` over CDP. Same as `metro reload`. | + +**Caveats** + +- Requires Hermes (`__REACT_DEVTOOLS_GLOBAL_HOOK__` is registered on Hermes startup). +- `debug component-tree` works on both Fabric and Paper, but the SKIP list of wrapper component names is hard-coded — new RN versions may surface new wrappers we don't filter. +- `debug inspect-element` requires `getInspectorDataForViewAtPoint` on the renderer; this exists on RN 0.70+. Older versions error out. +- Source frames come from `_debugStack` (RN ≥ 0.76) or `_debugSource` (`@babel/plugin-transform-react-jsx-source`). With neither, the frame is `null`. + +--- + +## Network inspection + +| Command | What it does | +| ----------------------------------------------- | ---------------------------------------------------------------------------------- | +| `network logs [--limit n]` | Install a `fetch`/`XHR` shim into the running app (idempotent — only once per JS context) and read the captured entries. Each entry: `{ id, kind, method, url, status, durationMs, error, start }`. | +| `network request [--method --body --header k=v]` | Issue an HTTP request from the app's network context. Honours the app's cookies, headers, and TLS pinning. | + +**Caveats** + +- Shim only sees `fetch` and `XMLHttpRequest` — apps that use native networking modules directly (e.g. `okhttp` on Android via a TurboModule) bypass it. +- A `metro reload` or app reload clears the shim — call `network logs` once to reinstall. +- The shim's ring buffer caps at ~200 entries; tune by re-installing if needed. + +--- + +## Profiling + +| Command | What it does | +| ---------------------------------- | ---------------------------------------------------------------------------------- | +| `profile cpu --duration ` | Record a CPU trace. iOS: `xcrun xctrace record --template "Time Profiler"`. Android: `adb shell simpleperf record`. Writes to `--out ` or a `tmp/` file. | +| `profile memory --track ` | Poll memory at `--interval ` (default 1000ms) for `track` seconds. Reports per-sample app + system memory; suitable for spotting leaks under repeated interactions. | +| `profile react start` | Install a React commit-profiler hook via `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`. Subsequent commits are collected into a ring buffer with per-component `actualDuration`. | +| `profile react stop [--top n]` | Stop the profiler, summarise the top N components by total render time across commits. | + +**Caveats** + +- `profile cpu` requires `xctrace` (Xcode) or `simpleperf` (Android NDK) on `PATH`. Argent's profiling tools also have a query layer over saved traces — Conductor only does record + summary today. +- `profile react` is Hermes-only and intercepts `onCommitFiberRoot` — interaction with other DevTools clients (the standalone React DevTools window, Flipper) is undefined; only run one profiler at a time. +- `profile memory` is a polling shim over `memory` — for finer detail use the underlying `conductor memory` directly. + +--- + +## When experimental graduates + +Each command above moves to the main [Command catalogue](/conductor/docs/commands) +once it survives a full RN minor-version cycle without script changes. Until then, +expect that an RN upgrade may briefly break one of these and a Conductor patch +release will follow. diff --git a/docs/marketing-manifest.json b/docs/marketing-manifest.json index 1ee991e..f2c164a 100644 --- a/docs/marketing-manifest.json +++ b/docs/marketing-manifest.json @@ -40,6 +40,12 @@ "file": "web.md", "title": "Web testing", "summary": "The same commands you use on iOS and Android, driving Playwright-managed Chromium, Firefox, or WebKit instead." + }, + { + "slug": "experimental", + "file": "experimental.md", + "title": "Experimental", + "summary": "RN debugger, network inspection, and profiling. Powerful but depend on React Native runtime internals — expect occasional drift between RN versions." } ] }, From e087a54b6a98dc0e177ff8537e5cc8fc90a08068 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 20 May 2026 23:53:58 +0200 Subject: [PATCH 13/14] fix(profile): remove unused fs import Drops the unused `fs` import in profile.ts that failed the no-unused-vars lint rule and broke CI on the improvements branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/profile.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/profile.ts b/packages/cli/src/commands/profile.ts index aeaffc1..8dde93b 100644 --- a/packages/cli/src/commands/profile.ts +++ b/packages/cli/src/commands/profile.ts @@ -6,7 +6,6 @@ export const HELP = ` profile cpu --duration [--out ] profile react stop [--top N] Stop and summarise captured React commits`; import { spawn } from 'child_process'; -import fs from 'fs'; import os from 'os'; import path from 'path'; import { printError, printData, printSuccess, OutputOptions } from '../output.js'; @@ -282,7 +281,10 @@ interface ReactProfilerReadResult { installed: boolean; totalCommits?: number; top?: Array<{ name: string; totalMs: number; renders: number }>; - commits?: Array<{ at: number; components: Array<{ name: string; depth: number; actualDuration: number }> }>; + commits?: Array<{ + at: number; + components: Array<{ name: string; depth: number; actualDuration: number }>; + }>; } export async function profileReactStart( @@ -306,7 +308,11 @@ export async function profileReactStart( return 1; } if (opts.json) printData(result, opts); - else printSuccess(`profile react start — ${result.already ? 'already installed' : 'installed'}`, opts); + else + printSuccess( + `profile react start — ${result.already ? 'already installed' : 'installed'}`, + opts + ); return 0; } catch (err) { printError(`profile react start — ${err instanceof Error ? err.message : String(err)}`, opts); From 90938bde5239c6bdfcd1b80ca47703858287ff41 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 20 May 2026 23:58:16 +0200 Subject: [PATCH 14/14] style: apply prettier formatting to crashes, debug, gestures, index These four files had lines that violated the prettier printWidth rule, failing `prettier --check` in CI once the preceding eslint error was resolved. Reformatted with `prettier --write`; no logic changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/crashes.ts | 22 +++++++++------------- packages/cli/src/commands/debug.ts | 4 +++- packages/cli/src/commands/gestures.ts | 5 +---- packages/cli/src/index.ts | 7 +------ 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/commands/crashes.ts b/packages/cli/src/commands/crashes.ts index 524c726..a95dda8 100644 --- a/packages/cli/src/commands/crashes.ts +++ b/packages/cli/src/commands/crashes.ts @@ -64,12 +64,7 @@ function listIosReports(opts: { app?: string; sinceMs: number }): CrashReport[] return out.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); } -function parseIpsReport( - id: string, - full: string, - text: string, - mtimeMs: number -): CrashReport { +function parseIpsReport(id: string, full: string, text: string, mtimeMs: number): CrashReport { // Newer .ips files are JSON-LD style: first line is summary JSON, then body JSON. // Older .crash files are plain text. Be defensive. let app: string | null = null; @@ -129,7 +124,8 @@ async function listAndroidReports( ): Promise { const adb = resolveAndroidTool('adb'); const env = androidSpawnEnv(); - const sinceArg = opts.sinceMs > 0 ? ['-T', String(Math.floor((Date.now() - opts.sinceMs) / 1000))] : []; + const sinceArg = + opts.sinceMs > 0 ? ['-T', String(Math.floor((Date.now() - opts.sinceMs) / 1000))] : []; const output: string = await new Promise((resolve) => { const proc = spawn(adb, ['-s', deviceId, 'logcat', '-d', '-b', 'crash', ...sinceArg], { stdio: ['ignore', 'pipe', 'ignore'], @@ -162,7 +158,9 @@ async function listAndroidReports( } reports.push({ id: `android-${i}-${tsMatch?.[1] ?? Date.now()}`, - timestamp: tsMatch ? new Date().getFullYear() + '-' + tsMatch[1].replace(' ', 'T') : new Date().toISOString(), + timestamp: tsMatch + ? new Date().getFullYear() + '-' + tsMatch[1].replace(' ', 'T') + : new Date().toISOString(), app, type: 'logcat', signal: sigMatch ? sigMatch[1] : null, @@ -186,7 +184,8 @@ export async function crashesList( listOpts: CrashesListOptions ): Promise { const sinceMs = parseSince(listOpts.since); - const platform = sessionName !== 'default' ? await detectPlatform(sessionName).catch(() => null) : null; + const platform = + sessionName !== 'default' ? await detectPlatform(sessionName).catch(() => null) : null; const reports: CrashReport[] = []; // Always include iOS host-side reports — they aren't device-scoped. @@ -208,10 +207,7 @@ export async function crashesList( return 0; } -export async function crashesShow( - id: string, - opts: OutputOptions -): Promise { +export async function crashesShow(id: string, opts: OutputOptions): Promise { if (!id) { printError('crashes show requires an ', opts); return 1; diff --git a/packages/cli/src/commands/debug.ts b/packages/cli/src/commands/debug.ts index 851bb53..826e5ba 100644 --- a/packages/cli/src/commands/debug.ts +++ b/packages/cli/src/commands/debug.ts @@ -198,7 +198,9 @@ export async function debugComponentTree( if (c.text) parts.push(`text=${JSON.stringify(c.text.slice(0, 40))}`); if (c.rect) { const r = c.rect; - parts.push(`[${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}]`); + parts.push( + `[${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}]` + ); } console.log(parts.join(' ')); } diff --git a/packages/cli/src/commands/gestures.ts b/packages/cli/src/commands/gestures.ts index ca48a31..9a8edba 100644 --- a/packages/cli/src/commands/gestures.ts +++ b/packages/cli/src/commands/gestures.ts @@ -125,10 +125,7 @@ function buildRotatePaths( return [{ steps: finger1 }, { steps: finger2 }]; } -async function playPaths( - driver: IOSDriver | AndroidDriver, - paths: FingerPath[] -): Promise { +async function playPaths(driver: IOSDriver | AndroidDriver, paths: FingerPath[]): Promise { if (driver instanceof IOSDriver) { await driver.gesturePath(paths); } else { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9f85245..4ad8ed0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -57,12 +57,7 @@ import { profileReactStop, HELP as profileHelp, } from './commands/profile.js'; -import { - crashesList, - crashesShow, - crashesTail, - HELP as crashesHelp, -} from './commands/crashes.js'; +import { crashesList, crashesShow, crashesTail, HELP as crashesHelp } from './commands/crashes.js'; import { getActiveRecording, appendStep, commandToYamlStep } from './drivers/flow-recorder.js'; import { foregroundApp, HELP as foregroundAppHelp } from './commands/foreground-app.js'; import { listApps, HELP as listAppsHelp } from './commands/list-apps.js';