@farmslot/adapter-rn 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1481 @@
1
+ /**
2
+ * Bridge core — the generic React Native CDP bridge CLI a host harness builds
3
+ * on. It attaches to the running app's Hermes runtime (directly, or through the
4
+ * CDP broker when one is running) and dispatches one command per process.
5
+ *
6
+ * The host owns a small preset file that calls `runBridgeCli(config)` with its
7
+ * product commands, route table, recovery hints and help lines; everything
8
+ * generic (UI gestures, eval, HUD, profiler, issue capture, network capture,
9
+ * the debugger-slot lock, target selection and typed errors) lives here.
10
+ * Loading this module runs nothing.
11
+ *
12
+ * Environment:
13
+ * WATCHER_PORT Metro port (default: 8081, read from .js.env if present)
14
+ * CDP_TIMEOUT Connection timeout in ms (default: 5000)
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+ const {
23
+ brokerSocketPath,
24
+ createBrokerClient,
25
+ deviceIdFromUrl,
26
+ } = require('@farmslot/recipe-runner/cdp-broker');
27
+ const { loadPort } = require('./lib/config.cjs');
28
+ const { discoverAllTargets, discoverTarget } = require('./lib/target-discovery.cjs');
29
+ const { createWSClient } = require('./lib/ws-client.cjs');
30
+ const {
31
+ BRIDGE_ERROR_CODES,
32
+ EXIT_CODE_BY_ERROR_CODE,
33
+ classifyBridgeErrorMessage,
34
+ coded,
35
+ formatErrorMarker,
36
+ } = require('./lib/bridge-errors.cjs');
37
+ const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
38
+ const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
39
+
40
+ /**
41
+ * A bridge command. Built-ins and host commands share this signature; the
42
+ * resolved value is printed as pretty JSON on stdout.
43
+ * @callback BridgeCommand
44
+ * @param {object} client CDP client (direct WebSocket or broker client).
45
+ * @param {string[]} args Arguments after the command name.
46
+ * @param {BridgeCommandContext} [context]
47
+ * @returns {Promise<unknown>}
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} BridgeCommandContext
52
+ * @property {string} [deviceName] Name of the selected target.
53
+ * @property {string} [platform] `__AGENTIC__.platform` of the target ('' for deferred commands).
54
+ * @property {string} [runtimeIdentity] Set for `status-selected` only.
55
+ */
56
+
57
+ /**
58
+ * Where a host command shows up in `--help` and in the unknown-command listing.
59
+ * An anchor names a command already placed (built-in or an earlier host
60
+ * command); without one the command goes last.
61
+ * @typedef {object} BridgeCommandDoc
62
+ * @property {string} [help] Verbatim `--help` line(s), without a trailing newline.
63
+ * @property {string} [helpAfter] Command whose help line this one follows.
64
+ * @property {string} [listAfter] Command this one follows in the `Available:` listing.
65
+ */
66
+
67
+ /**
68
+ * Route table for `navigate`.
69
+ * @typedef {object} BridgeRoutes
70
+ * @property {Record<string, string>} [aliases] Friendly name → route name.
71
+ * @property {Record<string, string>} [nestedParents] Nested route → parent
72
+ * navigator; such routes navigate as `navigate(parent, { screen, params })`.
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} BridgeCliConfig
77
+ * @property {Record<string, BridgeCommand>} [commands] Host commands. A name that
78
+ * collides with a built-in throws. A host `status` command is probed on every
79
+ * target, and `status-selected` runs it on the selected target only.
80
+ * @property {Record<string, BridgeCommandDoc>} [commandDocs] Help and listing
81
+ * placement for host commands.
82
+ * @property {BridgeRoutes} [routes] Route table used by `navigate`.
83
+ * @property {Record<string, string>} [teachingByErrorCode] Recovery hint printed
84
+ * after the `ERROR[<CODE>]` marker of a coded failure.
85
+ * @property {string} [appLabel] App name in the `--help` title.
86
+ * @property {string} [usagePath] Script path in the `--help` usage line
87
+ * (default: the script's file name).
88
+ * @property {string[]} [perfMarkerPrefixes] Console prefixes whose JSON payload
89
+ * `measure-scroll-transition` reads as a visible-stage event.
90
+ * @property {string} [readyExpression] JS expression evaluated when
91
+ * `measure-scroll-transition` gets `requireWalletReady: true`. It must return
92
+ * `{ ready: boolean, observedAtMs: number, ... }`; without it there is no
93
+ * readiness gate.
94
+ */
95
+
96
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
97
+ const bridge = this.__AGENTIC__;
98
+ if (step === null) {
99
+ if (typeof bridge?.hideStep !== 'function') return false;
100
+ setTimeout(() => bridge.hideStep(), 0);
101
+ return true;
102
+ }
103
+ if (typeof bridge?.showStep !== 'function') return false;
104
+ setTimeout(() => bridge.showStep(step), 0);
105
+ return true;
106
+ }`;
107
+
108
+ function mobileToolPath(tool) {
109
+ return process.env[`RECIPE_RN_${tool.toUpperCase()}_PATH`] || tool;
110
+ }
111
+
112
+ function bootedIosDevice(deviceName) {
113
+ if (!deviceName) return null;
114
+ let listing;
115
+ try {
116
+ listing = execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], {
117
+ encoding: 'utf8',
118
+ });
119
+ } catch {
120
+ // Recovery is correct: without a working `xcrun simctl` (no Xcode, or not
121
+ // macOS) no simulator is booted for us; callers fall back or report it.
122
+ return null;
123
+ }
124
+ const devices = JSON.parse(listing);
125
+ const device = Object.values(devices.devices || {})
126
+ .flat()
127
+ .find(
128
+ (candidate) =>
129
+ (candidate?.name === deviceName || candidate?.udid === deviceName) &&
130
+ candidate?.state === 'Booted',
131
+ );
132
+ return device?.udid ? device : null;
133
+ }
134
+
135
+ function tapVisibleIosAccessibilityTarget(testId, deviceName, platform) {
136
+ if (platform !== 'ios') return null;
137
+ const device = bootedIosDevice(deviceName);
138
+ if (!device) return null;
139
+ const idb = mobileToolPath('idb');
140
+ let describeOutput;
141
+ try {
142
+ describeOutput = execFileSync(idb, ['ui', 'describe-all', '--udid', device.udid, '--json'], {
143
+ encoding: 'utf8',
144
+ });
145
+ } catch {
146
+ // Recovery is correct: the native tap is a fallback; when idb cannot run,
147
+ // press-test-id reports the app bridge's own result or error instead.
148
+ return null;
149
+ }
150
+ const elements = JSON.parse(describeOutput);
151
+ const windowFrame = elements
152
+ .map((element) => element?.frame)
153
+ .filter(
154
+ (frame) => frame && frame.x === 0 && frame.y === 0 && frame.width > 0 && frame.height > 0,
155
+ )
156
+ .sort((first, second) => second.width * second.height - first.width * first.height)[0];
157
+ if (!windowFrame) return null;
158
+ const target = elements.find((element) => {
159
+ const frame = element?.frame;
160
+ return (
161
+ element?.AXUniqueId === testId &&
162
+ element?.enabled !== false &&
163
+ element?.hittable !== false &&
164
+ frame &&
165
+ frame.width > 0 &&
166
+ frame.height > 0 &&
167
+ frame.x >= 0 &&
168
+ frame.y >= 0 &&
169
+ frame.x + frame.width <= windowFrame.width &&
170
+ frame.y + frame.height <= windowFrame.height
171
+ );
172
+ });
173
+ if (!target) return null;
174
+ const x = Math.round(target.frame.x + target.frame.width / 2);
175
+ const y = Math.round(target.frame.y + target.frame.height / 2);
176
+ try {
177
+ execFileSync(idb, ['ui', 'tap', String(x), String(y), '--udid', device.udid], {
178
+ encoding: 'utf8',
179
+ });
180
+ } catch {
181
+ // Recovery is correct: same fallback contract as describe-all above.
182
+ return null;
183
+ }
184
+ return { ok: true, testId, deviceName, provider: 'idb-accessibility' };
185
+ }
186
+
187
+ async function applyHudUpdate(client, step) {
188
+ const globalObject = await client.send('Runtime.evaluate', {
189
+ expression: 'globalThis',
190
+ returnByValue: false,
191
+ awaitPromise: false,
192
+ });
193
+ const objectId = globalObject?.result?.objectId;
194
+ if (!objectId) {
195
+ throw new Error('Mobile HUD runtime did not expose globalThis');
196
+ }
197
+ const result = await client.send('Runtime.callFunctionOn', {
198
+ objectId,
199
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
200
+ arguments: [{ value: step }],
201
+ returnByValue: true,
202
+ awaitPromise: false,
203
+ });
204
+ if (result?.exceptionDetails) {
205
+ throw new Error(
206
+ result.exceptionDetails.exception?.description ||
207
+ result.exceptionDetails.text ||
208
+ 'Mobile HUD update failed',
209
+ );
210
+ }
211
+ return result?.result?.value === true;
212
+ }
213
+
214
+ function parsePerformanceConsoleEvent(params, markers) {
215
+ for (const arg of params?.args || []) {
216
+ const text = typeof arg?.value === 'string' ? arg.value : '';
217
+ const marker = markers.find((candidate) => text.includes(candidate));
218
+ if (!marker) continue;
219
+ const markerIndex = text.indexOf(marker);
220
+ try {
221
+ return JSON.parse(text.slice(markerIndex + marker.length));
222
+ } catch (error) {
223
+ // A marker followed by text that is not JSON is not a perf event; keep
224
+ // scanning the remaining console arguments.
225
+ if (!(error instanceof SyntaxError)) throw error;
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
231
+ async function setInput(client, testId, value, { deviceName } = {}, redact = false) {
232
+ if (!testId) {
233
+ throw new Error('Usage: set-input <testId> <value>');
234
+ }
235
+ const expr = `(function() {
236
+ if (globalThis.__AGENTIC__?.setInput) return Promise.resolve(globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}));
237
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
238
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
239
+ var renderers = hook.renderers;
240
+ if (!renderers) return { ok: false, error: 'No renderers' };
241
+ var getFiberRoots = hook.getFiberRoots;
242
+ function findByTestId(fiber) {
243
+ if (!fiber) return null;
244
+ var props = fiber.memoizedProps;
245
+ if (props && props.testID === ${JSON.stringify(testId)}) return fiber;
246
+ return findByTestId(fiber.child) || findByTestId(fiber.sibling);
247
+ }
248
+ for (var [id] of renderers) {
249
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
250
+ if (!roots) continue;
251
+ var result = null;
252
+ roots.forEach(function(r) {
253
+ if (result) return;
254
+ var fiber = findByTestId(r.current);
255
+ if (!fiber) return;
256
+ var cur = fiber;
257
+ while (cur) {
258
+ if (cur.memoizedProps && typeof cur.memoizedProps.onChangeText === 'function') {
259
+ cur.memoizedProps.onChangeText(${JSON.stringify(value)});
260
+ result = { ok: true, testId: ${JSON.stringify(testId)} };
261
+ return;
262
+ }
263
+ cur = cur.return || null;
264
+ }
265
+ });
266
+ if (result) return result;
267
+ }
268
+ return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onChangeText' };
269
+ })()`;
270
+ let result;
271
+ try {
272
+ result = await cdpEvalAsync(client, expr);
273
+ } catch (error) {
274
+ if (redact) {
275
+ throw new Error(`Secret input could not be applied for testID ${testId}.`);
276
+ }
277
+ throw error;
278
+ }
279
+ if (redact && result?.ok === false) {
280
+ result = { ok: false, error: 'Secret input could not be applied.' };
281
+ }
282
+ return {
283
+ ...result,
284
+ testId,
285
+ value: redact ? '<redacted>' : value,
286
+ deviceName,
287
+ };
288
+ }
289
+
290
+ function parseHudStep(args) {
291
+ const raw = args.join(' ');
292
+ let step;
293
+ try {
294
+ step = JSON.parse(raw);
295
+ } catch (error) {
296
+ throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
297
+ }
298
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
299
+ throw new Error('show-step-json requires a JSON object step payload');
300
+ }
301
+ if (typeof step.intent !== 'string' || !step.intent.trim()) {
302
+ throw new Error('show-step-json requires step.intent');
303
+ }
304
+ if (step.id !== undefined && typeof step.id !== 'string') {
305
+ throw new Error('show-step-json step.id must be a string when provided');
306
+ }
307
+ return step;
308
+ }
309
+
310
+ /**
311
+ * The generic command table, bound to the host's route table, perf markers and
312
+ * readiness expression.
313
+ * @param {Required<Pick<BridgeCliConfig, 'routes' | 'perfMarkerPrefixes'>> & Pick<BridgeCliConfig, 'readyExpression'>} options
314
+ * @returns {Record<string, BridgeCommand>}
315
+ */
316
+ function createBuiltinCommands({ routes, perfMarkerPrefixes, readyExpression }) {
317
+ const routeAliases = routes.aliases || {};
318
+ const nestedRouteParents = routes.nestedParents || {};
319
+ return {
320
+ async 'target-identity-selected'() {
321
+ throw new Error('target-identity-selected is resolved before CDP attachment');
322
+ },
323
+ async navigate(client, args, { deviceName, platform } = {}) {
324
+ const routeName = routeAliases[args[0]] || args[0];
325
+ if (!routeName) {
326
+ throw new Error('Usage: navigate <RouteName> [params-json]');
327
+ }
328
+ let params = {};
329
+ if (args[1]) {
330
+ try {
331
+ params = JSON.parse(args[1]);
332
+ } catch (e) {
333
+ throw new Error(`Invalid params JSON: ${e.message}`);
334
+ }
335
+ }
336
+
337
+ // Capture route before navigation
338
+ const previousRoute = await cdpEval(client, 'globalThis.__AGENTIC__?.getRoute()');
339
+
340
+ let expr;
341
+ const parent = nestedRouteParents[routeName];
342
+ if (parent) {
343
+ // Nested route: navigate('Parent', { screen: 'Child', params: {...} })
344
+ const navParams =
345
+ Object.keys(params).length > 0
346
+ ? JSON.stringify({ screen: routeName, params })
347
+ : JSON.stringify({ screen: routeName });
348
+ expr = `globalThis.__AGENTIC__?.navigate(${JSON.stringify(parent)}, ${navParams})`;
349
+ } else {
350
+ // Root-level route: navigate('Route', params)
351
+ const paramsStr = JSON.stringify(params);
352
+ expr = `globalThis.__AGENTIC__?.navigate(${JSON.stringify(routeName)}, ${paramsStr})`;
353
+ }
354
+
355
+ await cdpEval(client, expr);
356
+ // Small delay for navigation to settle, then return current route
357
+ await new Promise((r) => setTimeout(r, 500));
358
+ const currentRoute = await cdpEval(client, 'globalThis.__AGENTIC__?.getRoute()');
359
+ // Normalize route values: cdpEval returns undefined when the optional-chain
360
+ // short-circuits (mid-navigation transient or bridge not yet installed).
361
+ // null is valid JSON; undefined produces the literal string "undefined".
362
+ return {
363
+ navigated: routeName,
364
+ params,
365
+ previousRoute: previousRoute ?? null,
366
+ currentRoute: currentRoute ?? null,
367
+ deviceName,
368
+ platform,
369
+ };
370
+ },
371
+
372
+ async 'get-route'(client) {
373
+ const route = await cdpEval(client, 'globalThis.__AGENTIC__?.getRoute()');
374
+ // cdpEval returns undefined when the optional-chain short-circuits (bridge not
375
+ // yet installed, mid-navigation, or route state transiently missing). Return null
376
+ // so JSON.stringify produces valid JSON ("null") instead of literal "undefined".
377
+ return route ?? null;
378
+ },
379
+
380
+ async 'get-state'(client, args) {
381
+ const path = args[0] || '';
382
+ if (!path) {
383
+ // Return navigation state
384
+ const state = await cdpEval(client, 'globalThis?.__AGENTIC__?.getState()');
385
+ return state;
386
+ }
387
+ // Access Redux store at the given dot-path
388
+ const expr = `(() => {
389
+ const store = globalThis?.store;
390
+ if (!store) return { error: 'Redux store not found on globalThis.store' };
391
+ const state = store.getState();
392
+ const parts = ${JSON.stringify(path)}.split('.');
393
+ let current = state;
394
+ for (const p of parts) {
395
+ if (current == null) return undefined;
396
+ current = current[p];
397
+ }
398
+ return current;
399
+ })()`;
400
+ return await cdpEval(client, expr);
401
+ },
402
+
403
+ async eval(client, args) {
404
+ const expression = args.join(' ');
405
+ if (!expression) {
406
+ throw new Error('Usage: eval <expression>');
407
+ }
408
+ return await cdpEval(client, expression);
409
+ },
410
+
411
+ async 'eval-async'(client, args) {
412
+ const expression = args.join(' ');
413
+ if (!expression) {
414
+ throw new Error('Usage: eval-async <expression>');
415
+ }
416
+ return await cdpEvalAsync(client, expression);
417
+ },
418
+
419
+ async 'can-go-back'(client) {
420
+ return await cdpEval(client, 'globalThis.__AGENTIC__?.canGoBack()');
421
+ },
422
+
423
+ async 'go-back'(client, _args, { deviceName, platform } = {}) {
424
+ await cdpEval(client, 'globalThis.__AGENTIC__?.goBack()');
425
+ await new Promise((r) => setTimeout(r, 300));
426
+ const route = await cdpEval(client, 'globalThis.__AGENTIC__?.getRoute()');
427
+ // Same normalization as navigate/get-route: a transiently-missing route must
428
+ // serialize as "currentRoute": null, not have the key silently omitted.
429
+ return { currentRoute: route ?? null, deviceName, platform };
430
+ },
431
+
432
+ async 'press-test-id'(client, args, { deviceName, platform } = {}) {
433
+ const testId = args[0];
434
+ if (!testId) {
435
+ throw new Error('Usage: press-test-id <testId>');
436
+ }
437
+ // Try the app bridge first. Native accessibility remains an iOS fallback
438
+ // for controls whose React handler cannot be invoked directly.
439
+ const expr = `(function() {
440
+ if (globalThis.__AGENTIC__?.pressTestId) return Promise.resolve(globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)}));
441
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
442
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
443
+ var renderers = hook.renderers;
444
+ if (!renderers) return { ok: false, error: 'No renderers' };
445
+ var getFiberRoots = hook.getFiberRoots;
446
+ function walk(fiber) {
447
+ if (!fiber) return false;
448
+ var props = fiber.memoizedProps;
449
+ if (props && props.testID === ${JSON.stringify(testId)}) {
450
+ if (typeof props.onPress === 'function') { props.onPress(); return true; }
451
+ }
452
+ return walk(fiber.child) || walk(fiber.sibling);
453
+ }
454
+ for (var [id] of renderers) {
455
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
456
+ if (!roots) continue;
457
+ var found = false;
458
+ roots.forEach(function(r) { if (!found) found = walk(r.current); });
459
+ if (found) return { ok: true, testId: ${JSON.stringify(testId)} };
460
+ }
461
+ return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onPress' };
462
+ })()`;
463
+ let result;
464
+ let bridgeError;
465
+ try {
466
+ result = await cdpEvalAsync(client, expr);
467
+ if (result?.ok !== false) {
468
+ return { ...result, testId, deviceName };
469
+ }
470
+ } catch (error) {
471
+ bridgeError = error;
472
+ }
473
+ const nativeResult = tapVisibleIosAccessibilityTarget(testId, deviceName, platform);
474
+ if (nativeResult) return nativeResult;
475
+ if (bridgeError) throw bridgeError;
476
+ return { ...result, testId, deviceName };
477
+ },
478
+
479
+ async 'key-press'(_client, args, { deviceName, platform } = {}) {
480
+ const requestedKey = String(args[0] || 'Enter').toLowerCase();
481
+ const isEnter = requestedKey === 'enter' || requestedKey === 'return';
482
+ const isBack = requestedKey === 'escape' || requestedKey === 'back';
483
+ if (!isEnter && !isBack) {
484
+ throw new Error(`Unsupported Mobile key ${JSON.stringify(args[0])}`);
485
+ }
486
+ if (platform === 'android') {
487
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
488
+ if (!serial) throw new Error('key-press requires a selected Android device');
489
+ const keyCode = isEnter ? '66' : '4';
490
+ execFileSync(mobileToolPath('adb'), ['-s', serial, 'shell', 'input', 'keyevent', keyCode], {
491
+ encoding: 'utf8',
492
+ });
493
+ return {
494
+ ok: true,
495
+ key: args[0] || 'Enter',
496
+ keyCode,
497
+ deviceName,
498
+ provider: 'adb-key',
499
+ };
500
+ }
501
+ if (platform !== 'ios') {
502
+ throw new Error(`key-press requires a selected Mobile platform`);
503
+ }
504
+ const device = bootedIosDevice(deviceName);
505
+ if (!device) {
506
+ throw new Error(`No booted simulator named ${JSON.stringify(deviceName)}`);
507
+ }
508
+ const keyCode = isEnter ? '40' : '41';
509
+ execFileSync(mobileToolPath('idb'), ['ui', 'key', keyCode, '--udid', device.udid], {
510
+ encoding: 'utf8',
511
+ });
512
+ return {
513
+ ok: true,
514
+ key: args[0] || 'Enter',
515
+ keyCode,
516
+ deviceName,
517
+ provider: 'idb-key',
518
+ };
519
+ },
520
+
521
+ async 'long-press-test-id'(client, args, { deviceName } = {}) {
522
+ const testId = args[0];
523
+ if (!testId) {
524
+ throw new Error('Usage: long-press-test-id <testId>');
525
+ }
526
+ const expr = `(function() {
527
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
528
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
529
+ var renderers = hook.renderers;
530
+ if (!renderers) return { ok: false, error: 'No renderers' };
531
+ var getFiberRoots = hook.getFiberRoots;
532
+ function walk(fiber) {
533
+ if (!fiber) return false;
534
+ var props = fiber.memoizedProps;
535
+ if (props && props.testID === ${JSON.stringify(testId)}) {
536
+ if (typeof props.onLongPress === 'function') { props.onLongPress(); return true; }
537
+ }
538
+ return walk(fiber.child) || walk(fiber.sibling);
539
+ }
540
+ for (var [id] of renderers) {
541
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
542
+ if (!roots) continue;
543
+ var found = false;
544
+ roots.forEach(function(r) { if (!found) found = walk(r.current); });
545
+ if (found) return { ok: true, testId: ${JSON.stringify(testId)} };
546
+ }
547
+ return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onLongPress' };
548
+ })()`;
549
+ const result = await cdpEval(client, expr);
550
+ return { ...result, testId, deviceName };
551
+ },
552
+
553
+ async 'press-text'(client, args, { deviceName } = {}) {
554
+ const text = args[0];
555
+ if (!text) {
556
+ throw new Error('Usage: press-text <text>');
557
+ }
558
+ const expr = `(function() {
559
+ if (typeof globalThis.__AGENTIC__?.pressText !== 'function') {
560
+ return { ok: false, error: 'The app bridge does not expose pressText' };
561
+ }
562
+ return globalThis.__AGENTIC__.pressText(${JSON.stringify(text)});
563
+ })()`;
564
+ const result = await cdpEval(client, expr);
565
+ return { ...result, text, deviceName };
566
+ },
567
+
568
+ async 'scroll-view'(client, args, { deviceName } = {}) {
569
+ let testId;
570
+ let offset = 300;
571
+ let animated = false;
572
+ let intoView = false;
573
+ for (let i = 0; i < args.length; i++) {
574
+ if (args[i] === '--test-id' && i + 1 < args.length) {
575
+ testId = args[++i];
576
+ } else if (args[i] === '--offset' && i + 1 < args.length) {
577
+ offset = Number(args[++i]);
578
+ } else if (args[i] === '--animated') {
579
+ animated = true;
580
+ } else if (args[i] === '--into-view') {
581
+ intoView = true;
582
+ } else if (args[i] === '--no-animated') {
583
+ animated = false;
584
+ }
585
+ }
586
+ const optsJson = JSON.stringify({ testId, offset, animated, intoView });
587
+ // Try __AGENTIC__ bridge first, fall back to inline fiber walking
588
+ const expr = `(function() {
589
+ // The app helper implements generic scrolling by searching inside the
590
+ // selector fiber. For into-view requests the scroll container is commonly
591
+ // an ancestor (for example, a row inside a sectioned list), so use the
592
+ // ancestor-aware bridge fallback below instead.
593
+ if (${intoView} && globalThis.__AGENTIC__?.scrollIntoView) {
594
+ return globalThis.__AGENTIC__.scrollIntoView(${JSON.stringify(testId)}, ${animated});
595
+ }
596
+ if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
597
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
598
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
599
+ var renderers = hook.renderers;
600
+ if (!renderers) return { ok: false, error: 'No renderers' };
601
+ var getFiberRoots = hook.getFiberRoots;
602
+ var opts = ${optsJson};
603
+ function tryScroll(fiber, walkSiblings) {
604
+ if (walkSiblings === undefined) walkSiblings = true;
605
+ var current = fiber;
606
+ while (current) {
607
+ var rawStateNode = current.stateNode;
608
+ var sn = rawStateNode && rawStateNode.canonical && rawStateNode.canonical.publicInstance
609
+ ? rawStateNode.canonical.publicInstance
610
+ : rawStateNode;
611
+ if (sn) {
612
+ if (typeof sn.scrollTo === 'function') { sn.scrollTo({ y: opts.offset, animated: opts.animated }); return true; }
613
+ if (typeof sn.scrollToOffset === 'function') { sn.scrollToOffset({ offset: opts.offset, animated: opts.animated }); return true; }
614
+ }
615
+ if (tryScroll(current.child)) return true;
616
+ current = walkSiblings ? current.sibling : null;
617
+ }
618
+ return false;
619
+ }
620
+ function findMeasurable(fiber) {
621
+ if (!fiber) return null;
622
+ var rawStateNode = fiber.stateNode;
623
+ var sn = rawStateNode && rawStateNode.canonical && rawStateNode.canonical.publicInstance
624
+ ? rawStateNode.canonical.publicInstance
625
+ : rawStateNode;
626
+ if (sn && typeof sn.measureLayout === 'function') return sn;
627
+ return findMeasurable(fiber.child);
628
+ }
629
+ function tryScrollNear(anchor) {
630
+ if (!opts.intoView && tryScroll(anchor, false)) return true;
631
+ var target = opts.intoView ? findMeasurable(anchor) : null;
632
+ var current = anchor ? anchor.return : null;
633
+ while (current) {
634
+ var rawStateNode = current.stateNode;
635
+ var sn = rawStateNode && rawStateNode.canonical && rawStateNode.canonical.publicInstance
636
+ ? rawStateNode.canonical.publicInstance
637
+ : rawStateNode;
638
+ var currentProps = current.memoizedProps;
639
+ if (sn) {
640
+ if (typeof sn.scrollTo === 'function' && !(currentProps && currentProps.horizontal === true)) {
641
+ if (target) {
642
+ return new Promise(function(resolve) {
643
+ target.measureLayout(
644
+ sn,
645
+ function(x, y) {
646
+ sn.scrollTo({ y: y, animated: opts.animated });
647
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
648
+ },
649
+ function() {
650
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
651
+ }
652
+ );
653
+ });
654
+ }
655
+ sn.scrollTo({ y: opts.offset, animated: opts.animated });
656
+ return true;
657
+ }
658
+ if (typeof sn.scrollToOffset === 'function' && !(currentProps && currentProps.horizontal === true)) {
659
+ if (target) {
660
+ return new Promise(function(resolve) {
661
+ target.measureLayout(
662
+ sn,
663
+ function(x, y) {
664
+ sn.scrollToOffset({ offset: y, animated: opts.animated });
665
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
666
+ },
667
+ function() {
668
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
669
+ }
670
+ );
671
+ });
672
+ }
673
+ sn.scrollToOffset({ offset: opts.offset, animated: opts.animated });
674
+ return true;
675
+ }
676
+ }
677
+ current = current.return;
678
+ }
679
+ return opts.intoView ? tryScroll(anchor, false) : false;
680
+ }
681
+ function findTestId(fiber) {
682
+ if (!fiber) return null;
683
+ var props = fiber.memoizedProps;
684
+ if (props && props.testID === opts.testId) {
685
+ var ancestor = fiber;
686
+ var inactive = false;
687
+ while (ancestor) {
688
+ var ancestorProps = ancestor.memoizedProps;
689
+ var ancestorStyle = ancestorProps && ancestorProps.style;
690
+ if (
691
+ (ancestorProps && ancestorProps.activityState === 0) ||
692
+ (ancestorStyle && ancestorStyle.display === 'none')
693
+ ) {
694
+ inactive = true;
695
+ break;
696
+ }
697
+ ancestor = ancestor.return;
698
+ }
699
+ if (!inactive) return fiber;
700
+ }
701
+ return findTestId(fiber.child) || findTestId(fiber.sibling);
702
+ }
703
+ for (var [id] of renderers) {
704
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
705
+ if (!roots) continue;
706
+ var scrolled = false;
707
+ roots.forEach(function(r) {
708
+ if (scrolled) return;
709
+ if (opts.testId) {
710
+ var anchor = findTestId(r.current);
711
+ if (anchor) scrolled = tryScrollNear(anchor);
712
+ } else {
713
+ scrolled = tryScroll(r.current);
714
+ }
715
+ });
716
+ if (scrolled) {
717
+ if (typeof scrolled.then === 'function') return scrolled;
718
+ return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
719
+ }
720
+ }
721
+ return { ok: false, error: opts.testId ? 'No scrollable near testID=' + opts.testId : 'No scrollable found' };
722
+ })()`;
723
+ const result = intoView ? await cdpEvalAsync(client, expr) : await cdpEval(client, expr);
724
+ return { ...result, deviceName };
725
+ },
726
+
727
+ async 'measure-scroll-transition'(client, args, { deviceName } = {}) {
728
+ let options;
729
+ try {
730
+ options = JSON.parse(args[0] || '{}');
731
+ } catch (error) {
732
+ throw new Error(`Invalid measure-scroll-transition options: ${error.message}`);
733
+ }
734
+ const timeoutMs = Number(options.timeoutMs);
735
+ const pollIntervalMs = Number(options.pollIntervalMs ?? 100);
736
+ const visibleEventStage = options.visibleEventStage || null;
737
+ const visibleEventGraceMs = Number(options.visibleEventGraceMs ?? 0);
738
+ const targetCount =
739
+ Number(Boolean(options.targetTestId)) + Number(Boolean(options.targetText));
740
+ if (
741
+ !options.fromTestId ||
742
+ targetCount !== 1 ||
743
+ !Number.isFinite(timeoutMs) ||
744
+ timeoutMs <= 0 ||
745
+ !Number.isFinite(pollIntervalMs) ||
746
+ pollIntervalMs < 16 ||
747
+ (visibleEventStage !== null && typeof visibleEventStage !== 'string') ||
748
+ !Number.isFinite(visibleEventGraceMs) ||
749
+ visibleEventGraceMs < 0
750
+ ) {
751
+ throw new Error(
752
+ 'measure-scroll-transition requires fromTestId, exactly one targetTestId or targetText, a positive timeoutMs, pollIntervalMs >= 16, and a non-negative visibleEventGraceMs.',
753
+ );
754
+ }
755
+ const visibleEvents = [];
756
+ let removeConsoleListener = null;
757
+ if (visibleEventStage) {
758
+ removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
759
+ const payload = parsePerformanceConsoleEvent(params, perfMarkerPrefixes);
760
+ if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
761
+ });
762
+ await client.send('Runtime.enable');
763
+ }
764
+ const targetQuery = options.targetTestId
765
+ ? { testId: options.targetTestId, visibility: 'viewport' }
766
+ : { textContains: options.targetText, visibility: 'viewport' };
767
+ const from = await cdpEvalAsync(
768
+ client,
769
+ `globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
770
+ testId: options.fromTestId,
771
+ visibility: 'viewport',
772
+ })})`,
773
+ timeoutMs,
774
+ );
775
+ if (from?.visible !== true) {
776
+ return {
777
+ ok: false,
778
+ error: 'measurement start target is not visible',
779
+ from,
780
+ deviceName,
781
+ };
782
+ }
783
+ let walletReady = null;
784
+ if (options.requireWalletReady === true && readyExpression) {
785
+ walletReady = await cdpEval(client, readyExpression);
786
+ if (walletReady?.ready !== true) {
787
+ return {
788
+ ok: false,
789
+ error: 'wallet is not ready at measurement start',
790
+ walletReady,
791
+ from,
792
+ deviceName,
793
+ };
794
+ }
795
+ }
796
+ const started = await cdpEvalAsync(
797
+ client,
798
+ `(function(){
799
+ const scrollStartedAtMs = performance.now();
800
+ const startedAtMs = ${
801
+ walletReady === null
802
+ ? 'scrollStartedAtMs'
803
+ : JSON.stringify(Number(walletReady.observedAtMs))
804
+ };
805
+ return Promise.resolve(globalThis.__AGENTIC__?.scrollView(${JSON.stringify({
806
+ testId: options.scrollTestId,
807
+ offset: options.offset,
808
+ animated: options.animated,
809
+ intoView: false,
810
+ })})).then(function(scroll){ return { startedAtMs, scrollStartedAtMs, scroll }; });
811
+ })()`,
812
+ timeoutMs,
813
+ );
814
+ if (started?.scroll?.ok === false) {
815
+ return {
816
+ ok: false,
817
+ error: 'scroll failed',
818
+ ...started,
819
+ from,
820
+ deviceName,
821
+ };
822
+ }
823
+ const hostDeadline = Date.now() + timeoutMs;
824
+ if (visibleEventStage && visibleEventGraceMs > 0) {
825
+ const eventDeadline = Math.min(hostDeadline, Date.now() + visibleEventGraceMs);
826
+ while (Date.now() <= eventDeadline) {
827
+ const event = visibleEvents.find(
828
+ (candidate) =>
829
+ Number(candidate.frame_checkpoint_monotonic_ms) >= Number(started.scrollStartedAtMs),
830
+ );
831
+ if (event) {
832
+ removeConsoleListener?.();
833
+ const observedAtMs = Number(event.frame_checkpoint_monotonic_ms);
834
+ return {
835
+ ok: true,
836
+ clock: 'performance.now',
837
+ observationSource: 'runtime-console-event',
838
+ durationMs: observedAtMs - started.startedAtMs,
839
+ scrollDurationMs: observedAtMs - started.scrollStartedAtMs,
840
+ appVisibleDurationMs: Number(event.duration_ms),
841
+ pollIntervalMs,
842
+ preScrollDelayMs: started.scrollStartedAtMs - started.startedAtMs,
843
+ startedAtMs: started.startedAtMs,
844
+ scrollStartedAtMs: started.scrollStartedAtMs,
845
+ observedAtMs,
846
+ visibleEvent: event,
847
+ ...(walletReady ? { walletReady } : {}),
848
+ from,
849
+ scroll: started.scroll,
850
+ deviceName,
851
+ };
852
+ }
853
+ await new Promise((resolve) => setTimeout(resolve, 25));
854
+ }
855
+ }
856
+ while (Date.now() <= hostDeadline) {
857
+ const remainingMs = hostDeadline - Date.now();
858
+ if (remainingMs <= 1_000) break;
859
+ const queryTimeoutMs = Math.min(5_000, remainingMs);
860
+ const observation = await cdpEvalAsync(
861
+ client,
862
+ `(function(){
863
+ return Promise.resolve(globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify(targetQuery)})).then(function(target){
864
+ return { target, observedAtMs: performance.now() };
865
+ });
866
+ })()`,
867
+ queryTimeoutMs,
868
+ );
869
+ const requiredPresent =
870
+ observation?.target?.visible === true && options.requiredPresentTestId
871
+ ? await cdpEvalAsync(
872
+ client,
873
+ `globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
874
+ testId: options.requiredPresentTestId,
875
+ visibility: 'tree',
876
+ })})`,
877
+ queryTimeoutMs,
878
+ )
879
+ : null;
880
+ if (
881
+ observation?.target?.visible === true &&
882
+ (!options.requiredPresentTestId || requiredPresent?.present === true)
883
+ ) {
884
+ return {
885
+ ok: true,
886
+ clock: 'performance.now',
887
+ durationMs: observation.observedAtMs - started.startedAtMs,
888
+ scrollDurationMs: observation.observedAtMs - started.scrollStartedAtMs,
889
+ pollIntervalMs,
890
+ observationSource: visibleEventStage ? 'fiber-poll-after-event-grace' : 'fiber-poll',
891
+ preScrollDelayMs: started.scrollStartedAtMs - started.startedAtMs,
892
+ startedAtMs: started.startedAtMs,
893
+ scrollStartedAtMs: started.scrollStartedAtMs,
894
+ observedAtMs: observation.observedAtMs,
895
+ ...(walletReady ? { walletReady } : {}),
896
+ from,
897
+ target: observation.target,
898
+ ...(requiredPresent ? { requiredPresent } : {}),
899
+ scroll: started.scroll,
900
+ deviceName,
901
+ };
902
+ }
903
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
904
+ }
905
+ removeConsoleListener?.();
906
+ return {
907
+ ok: false,
908
+ error: 'timed out waiting for visible transition target',
909
+ durationMs: await cdpEval(client, `performance.now() - ${Number(started.startedAtMs)}`),
910
+ startedAtMs: started.startedAtMs,
911
+ scrollStartedAtMs: started.scrollStartedAtMs,
912
+ deviceName,
913
+ };
914
+ },
915
+
916
+ async 'set-input'(client, args, context = {}) {
917
+ return setInput(client, args[0], args.slice(1).join(' '), context);
918
+ },
919
+
920
+ async 'set-input-file'(client, args, context = {}) {
921
+ const testId = args[0];
922
+ const file = args[1];
923
+ if (!testId || !file) {
924
+ throw new Error('Usage: set-input-file <testId> <value-file>');
925
+ }
926
+ const stat = fs.lstatSync(file);
927
+ if (!stat.isFile() || stat.isSymbolicLink()) {
928
+ throw new Error('set-input-file requires a regular value file.');
929
+ }
930
+ return setInput(client, testId, fs.readFileSync(file, 'utf8'), context, true);
931
+ },
932
+
933
+ async 'sentry-debug'(client, args) {
934
+ const action = args[0] || 'enable';
935
+ if (action === 'enable') {
936
+ const expr = `(function() {
937
+ try {
938
+ var sentryHub = globalThis.__SENTRY__;
939
+ if (!sentryHub) return { ok: false, error: 'globalThis.__SENTRY__ not found' };
940
+ var ver = Object.keys(sentryHub).filter(function(k) { return /^[0-9]/.test(k); })[0];
941
+ if (!ver) return { ok: false, error: 'No Sentry version key found in __SENTRY__' };
942
+ var hub = sentryHub[ver];
943
+ var scope = hub.defaultCurrentScope;
944
+ if (!scope) return { ok: false, error: 'No defaultCurrentScope in Sentry hub' };
945
+ var sentryClient = scope.getClient();
946
+ if (!sentryClient) return { ok: false, error: 'No Sentry client found via scope.getClient()' };
947
+
948
+ if (sentryClient.__agenticPatched) return { ok: true, alreadyPatched: true, version: ver };
949
+
950
+ var origException = sentryClient.captureException.bind(sentryClient);
951
+ var origMessage = sentryClient.captureMessage.bind(sentryClient);
952
+
953
+ sentryClient.captureException = function(err, hint, currentScope) {
954
+ var msg = err && err.message ? err.message : String(err);
955
+ console.warn('[SENTRY-DEBUG] captureException:', msg);
956
+ if (err && err.stack) console.warn('[SENTRY-DEBUG] stack:', err.stack);
957
+ return origException(err, hint, currentScope);
958
+ };
959
+ sentryClient.captureMessage = function(msg, level, hint, currentScope) {
960
+ console.warn('[SENTRY-DEBUG] captureMessage:', msg);
961
+ return origMessage(msg, level, hint, currentScope);
962
+ };
963
+ sentryClient.__agenticPatched = true;
964
+ sentryClient.__agenticOrigException = origException;
965
+ sentryClient.__agenticOrigMessage = origMessage;
966
+ return { ok: true, patched: true, version: ver };
967
+ } catch(e) {
968
+ return { ok: false, error: String(e) };
969
+ }
970
+ })()`;
971
+ return await cdpEval(client, expr);
972
+ } else if (action === 'disable') {
973
+ const expr = `(function() {
974
+ try {
975
+ var sentryHub = globalThis.__SENTRY__;
976
+ if (!sentryHub) return { ok: true, wasNotPatched: true };
977
+ var ver = Object.keys(sentryHub).filter(function(k) { return /^[0-9]/.test(k); })[0];
978
+ if (!ver) return { ok: true, wasNotPatched: true };
979
+ var hub = sentryHub[ver];
980
+ var scope = hub.defaultCurrentScope;
981
+ if (!scope) return { ok: true, wasNotPatched: true };
982
+ var sentryClient = scope.getClient();
983
+ if (!sentryClient || !sentryClient.__agenticPatched) return { ok: true, wasNotPatched: true };
984
+ sentryClient.captureException = sentryClient.__agenticOrigException;
985
+ sentryClient.captureMessage = sentryClient.__agenticOrigMessage;
986
+ delete sentryClient.__agenticPatched;
987
+ delete sentryClient.__agenticOrigException;
988
+ delete sentryClient.__agenticOrigMessage;
989
+ return { ok: true, unpatched: true, version: ver };
990
+ } catch(e) {
991
+ return { ok: false, error: String(e) };
992
+ }
993
+ })()`;
994
+ return await cdpEval(client, expr);
995
+ }
996
+ throw new Error('Usage: sentry-debug [enable|disable]');
997
+ },
998
+
999
+ async 'show-step-json'(client, args) {
1000
+ const step = parseHudStep(args);
1001
+ await applyHudUpdate(client, step);
1002
+ return { ok: true };
1003
+ },
1004
+
1005
+ async 'show-step-json-deferred'(client, args) {
1006
+ const step = parseHudStep(args);
1007
+ if (typeof client.control === 'function') {
1008
+ return client.control('hud-update', { step });
1009
+ }
1010
+ await applyHudUpdate(client, step);
1011
+ return { ok: true, status: 'applied' };
1012
+ },
1013
+
1014
+ async 'hide-step'(client) {
1015
+ await applyHudUpdate(client, null);
1016
+ return { ok: true };
1017
+ },
1018
+
1019
+ async 'hide-step-deferred'(client) {
1020
+ if (typeof client.control === 'function') {
1021
+ return client.control('hud-update', { step: null });
1022
+ }
1023
+ await applyHudUpdate(client, null);
1024
+ return { ok: true, status: 'applied' };
1025
+ },
1026
+
1027
+ async 'profiler-start'(client) {
1028
+ // Hermes CDP exposes the sampling profiler via the Profiler domain.
1029
+ // Output of Profiler.stop is a Chrome-compatible .cpuprofile object.
1030
+ // Note: Hermes does NOT implement Profiler.enable (returns -32601 Unsupported);
1031
+ // Profiler.start/stop work without it.
1032
+ await client.send('Profiler.start');
1033
+ return { ok: true, started: true };
1034
+ },
1035
+
1036
+ async 'profiler-stop'(client, args) {
1037
+ let outPath = '';
1038
+ let label = 'trace';
1039
+ for (let i = 0; i < args.length; i++) {
1040
+ if (args[i] === '--out' && i + 1 < args.length) {
1041
+ outPath = args[++i];
1042
+ } else if (args[i] === '--label' && i + 1 < args.length) {
1043
+ label = args[++i];
1044
+ }
1045
+ }
1046
+ const res = await client.send('Profiler.stop', {}, 60000);
1047
+ const profile = res?.profile;
1048
+ if (!profile) {
1049
+ return { ok: false, error: 'Profiler.stop returned no profile' };
1050
+ }
1051
+ const serialized = JSON.stringify(profile);
1052
+ if (!outPath) {
1053
+ const tracesDir = path.resolve(
1054
+ process.env.APP_ROOT || process.cwd(),
1055
+ 'temp/agentic/traces',
1056
+ );
1057
+ fs.mkdirSync(tracesDir, { recursive: true });
1058
+ outPath = path.join(tracesDir, `trace-${label}.cpuprofile`);
1059
+ } else {
1060
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1061
+ }
1062
+ fs.writeFileSync(outPath, serialized);
1063
+ const nodesCount = Array.isArray(profile.nodes) ? profile.nodes.length : 0;
1064
+ const samplesCount = Array.isArray(profile.samples) ? profile.samples.length : 0;
1065
+ return {
1066
+ ok: true,
1067
+ path: outPath,
1068
+ label,
1069
+ sizeBytes: Buffer.byteLength(serialized, 'utf8'),
1070
+ nodesCount,
1071
+ samplesCount,
1072
+ startTime: profile.startTime ?? null,
1073
+ endTime: profile.endTime ?? null,
1074
+ };
1075
+ },
1076
+
1077
+ async 'issues-arm'(client) {
1078
+ // Installs console.warn/error and global error/unhandledrejection hooks
1079
+ // that push into globalThis.__AGENTIC_ISSUES__ (capped at 500 entries).
1080
+ // Idempotent — subsequent calls return { installed: true, reason: 'already-installed' }.
1081
+ const result = await cdpEval(client, buildArmSnippet());
1082
+ return result || { installed: false };
1083
+ },
1084
+
1085
+ async 'issues-collect'(client) {
1086
+ // Snapshots and clears globalThis.__AGENTIC_ISSUES__.
1087
+ // Returns { count, entries } where entries are { t, level, text }.
1088
+ const result = await cdpEval(client, buildCollectSnippet());
1089
+ return result || { count: 0, entries: [] };
1090
+ },
1091
+
1092
+ async 'network-capture-start'(client, args) {
1093
+ if (typeof client.control !== 'function') {
1094
+ throw new Error('Network capture requires the CDP broker');
1095
+ }
1096
+ const options = JSON.parse(args[0] || '{}');
1097
+ return client.control('capture-start', options);
1098
+ },
1099
+
1100
+ async 'network-capture-end'(client, args) {
1101
+ if (typeof client.control !== 'function') {
1102
+ throw new Error('Network capture requires the CDP broker');
1103
+ }
1104
+ const options = JSON.parse(args[0] || '{}');
1105
+ return client.control('capture-end', options);
1106
+ },
1107
+ };
1108
+ }
1109
+ // ---------------------------------------------------------------------------
1110
+ // Help
1111
+ // ---------------------------------------------------------------------------
1112
+
1113
+ /** `--help` lines of the built-in commands, in display order. */
1114
+ const BUILTIN_HELP = [
1115
+ ['navigate', ' navigate <RouteName> [params-json] Navigate to a screen'],
1116
+ ['get-route', ' get-route Get current route name and params'],
1117
+ ['get-state', ' get-state [dot.path] Get Redux state (or nav state if no path)'],
1118
+ ['eval', ' eval <expression> Evaluate arbitrary JS in app context'],
1119
+ ['eval-async', ' eval-async <expression> Evaluate async/Promise expression'],
1120
+ ['can-go-back', ' can-go-back Check if navigation can go back'],
1121
+ ['go-back', ' go-back Navigate back'],
1122
+ ['press-test-id', ' press-test-id <testId> Press a component by its testID prop'],
1123
+ [
1124
+ 'long-press-test-id',
1125
+ " long-press-test-id <testId> Invoke a component's long-press gesture by testID",
1126
+ ],
1127
+ [
1128
+ 'press-text',
1129
+ ' press-text <text> Press a component containing visible text',
1130
+ ],
1131
+ [
1132
+ 'scroll-view',
1133
+ ` scroll-view [--test-id <id>] [--offset <n>] [--animated]
1134
+ Scroll a ScrollView/FlatList`,
1135
+ ],
1136
+ [
1137
+ 'measure-scroll-transition',
1138
+ ' measure-scroll-transition <json> Scroll and measure a visible target using one CDP session',
1139
+ ],
1140
+ [
1141
+ 'set-input',
1142
+ ' set-input <testId> <value> Set text input value by testID (calls onChangeText)',
1143
+ ],
1144
+ [
1145
+ 'set-input-file',
1146
+ ' set-input-file <testId> <value-file> Set text input from a regular file and redact the value',
1147
+ ],
1148
+ [
1149
+ 'sentry-debug',
1150
+ ' sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix',
1151
+ ],
1152
+ ['profiler-start', ' profiler-start Start Hermes sampling profiler'],
1153
+ [
1154
+ 'profiler-stop',
1155
+ ` profiler-stop [--out <path>] [--label <name>]
1156
+ Stop profiler, dump Chrome-compatible
1157
+ .cpuprofile to <path> (default:
1158
+ temp/agentic/traces/trace-<label>.cpuprofile)`,
1159
+ ],
1160
+ [
1161
+ 'issues-arm',
1162
+ ` issues-arm Install console/exception hooks that
1163
+ populate globalThis.__AGENTIC_ISSUES__`,
1164
+ ],
1165
+ [
1166
+ 'issues-collect',
1167
+ ' issues-collect Snapshot + clear the in-app issue buffer',
1168
+ ],
1169
+ [
1170
+ 'network-capture-start',
1171
+ ' network-capture-start <json> Start a broker-owned Network capture',
1172
+ ],
1173
+ [
1174
+ 'network-capture-end',
1175
+ ' network-capture-end <json> End capture and return redacted summary',
1176
+ ],
1177
+ ];
1178
+
1179
+ /**
1180
+ * Insert `name` after `anchor` in `order`, or last without an anchor.
1181
+ * @param {string[]} order
1182
+ * @param {string} name
1183
+ * @param {string | undefined} anchor
1184
+ * @param {string} field Config field named in the startup error.
1185
+ */
1186
+ function placeAfter(order, name, anchor, field) {
1187
+ if (!anchor) {
1188
+ order.push(name);
1189
+ return;
1190
+ }
1191
+ const index = order.indexOf(anchor);
1192
+ if (index === -1) {
1193
+ throw new Error(`bridge-core: commandDocs.${name}.${field} names unknown command ${anchor}`);
1194
+ }
1195
+ order.splice(index + 1, 0, name);
1196
+ }
1197
+
1198
+ // ---------------------------------------------------------------------------
1199
+ // Main
1200
+ // ---------------------------------------------------------------------------
1201
+
1202
+ /**
1203
+ * Run the bridge CLI for `process.argv`: print help, or resolve the target,
1204
+ * run one command and print its JSON result. Failures exit with the typed
1205
+ * bridge exit code. Invalid host config throws before anything runs.
1206
+ * @param {BridgeCliConfig} [config]
1207
+ * @returns {Promise<void>}
1208
+ */
1209
+ function runBridgeCli(config = {}) {
1210
+ const hostCommands = config.commands || {};
1211
+ const commandDocs = config.commandDocs || {};
1212
+ const teachingByErrorCode = config.teachingByErrorCode || {};
1213
+ const appLabel = config.appLabel || 'React Native';
1214
+ const usagePath = config.usagePath || path.basename(process.argv[1] || 'bridge.cjs');
1215
+ const builtins = createBuiltinCommands({
1216
+ routes: config.routes || {},
1217
+ perfMarkerPrefixes: config.perfMarkerPrefixes || [],
1218
+ readyExpression: config.readyExpression,
1219
+ });
1220
+
1221
+ /** @type {Record<string, BridgeCommand>} */
1222
+ const commands = { ...builtins };
1223
+ const listing = Object.keys(builtins);
1224
+ const helpOrder = BUILTIN_HELP.map(([name]) => name);
1225
+ const helpTextByName = new Map(BUILTIN_HELP);
1226
+ for (const name of Object.keys(commandDocs)) {
1227
+ if (!Object.hasOwn(hostCommands, name)) {
1228
+ throw new Error(`bridge-core: commandDocs names unknown host command ${name}`);
1229
+ }
1230
+ }
1231
+ for (const [name, handler] of Object.entries(hostCommands)) {
1232
+ if (Object.hasOwn(builtins, name)) {
1233
+ throw new Error(`bridge-core: host command ${name} collides with a built-in command`);
1234
+ }
1235
+ // `status-selected` is the core's single-target form of the host `status`.
1236
+ if (name === 'status-selected') {
1237
+ throw new Error('bridge-core: status-selected is reserved; register status instead');
1238
+ }
1239
+ commands[name] = handler;
1240
+ const doc = commandDocs[name] || {};
1241
+ placeAfter(listing, name, doc.listAfter, 'listAfter');
1242
+ if (doc.help !== undefined) {
1243
+ placeAfter(helpOrder, name, doc.helpAfter, 'helpAfter');
1244
+ helpTextByName.set(name, doc.help);
1245
+ }
1246
+ }
1247
+
1248
+ // Bridge-priority lock: the Hermes inspector proxy reliably serves one debugger
1249
+ // slot, so the console-forwarder yields it while a bridge command runs (it
1250
+ // watches this file and backfills from the runtime's console buffer afterward).
1251
+ const bridgeLockFile = path.join(
1252
+ process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
1253
+ 'cdp-bridge.lock',
1254
+ );
1255
+ const bridgeLockOwnerPid = /^\d+$/.test(process.env.CDP_BRIDGE_LOCK_OWNER_PID || '')
1256
+ ? process.env.CDP_BRIDGE_LOCK_OWNER_PID
1257
+ : String(process.pid);
1258
+
1259
+ // Ownership semantics: last writer wins. Each bridge stamps its own pid; the
1260
+ // forwarder honors the lock while the pid IN THE FILE is alive. Overlapping
1261
+ // bridge commands therefore keep the slot guarded as long as the most recent
1262
+ // writer runs, and an earlier command that outlives it may lose the slot — one
1263
+ // bridge command at a time is the supported concurrency.
1264
+ let bridgeLockHeld = false;
1265
+
1266
+ function acquireBridgeLock() {
1267
+ try {
1268
+ fs.writeFileSync(bridgeLockFile, bridgeLockOwnerPid);
1269
+ bridgeLockHeld = true;
1270
+ } catch (error) {
1271
+ // Lock is best-effort: a missing runtime dir must not break bridge commands.
1272
+ if (error.code !== 'ENOENT') throw error;
1273
+ }
1274
+ }
1275
+
1276
+ process.on('exit', () => {
1277
+ // Only the current owner removes the lock: a no-lock invocation (--help,
1278
+ // unknown command) or a bridge whose lock was overwritten by a later one
1279
+ // must not release the slot under the still-running writer.
1280
+ if (!bridgeLockHeld) return;
1281
+ try {
1282
+ if (fs.readFileSync(bridgeLockFile, 'utf8').trim() === String(process.pid)) {
1283
+ fs.unlinkSync(bridgeLockFile);
1284
+ }
1285
+ } catch (error) {
1286
+ // Already gone.
1287
+ if (error.code !== 'ENOENT') throw error;
1288
+ }
1289
+ });
1290
+
1291
+ // Signal-killed bridges (status-probe kills timed-out probes SIGTERM→SIGKILL)
1292
+ // skip 'exit' unless a handler turns the signal into process.exit — without
1293
+ // this the lock strands and mutes console forwarding until staleness lapses.
1294
+ process.on('SIGTERM', () => process.exit(143));
1295
+ process.on('SIGINT', () => process.exit(130));
1296
+
1297
+ async function main() {
1298
+ const args = process.argv.slice(2);
1299
+ const command = args[0];
1300
+
1301
+ if (!command || command === '--help' || command === '-h') {
1302
+ console.log(`CDP Bridge — interact with the running ${appLabel} app via Hermes CDP
1303
+
1304
+ Usage:
1305
+ node ${usagePath} <command> [args...]
1306
+
1307
+ Commands:
1308
+ ${helpOrder.map((name) => helpTextByName.get(name)).join('\n')}
1309
+
1310
+ Environment:
1311
+ WATCHER_PORT Metro port (default: 8081)
1312
+ CDP_TIMEOUT Connection timeout in ms (default: 5000)
1313
+ IOS_SIMULATOR Filter targets by iOS simulator name
1314
+ ANDROID_DEVICE Filter targets by Android device/emulator serial`);
1315
+ process.exit(0);
1316
+ }
1317
+
1318
+ const handler = commands[command === 'status-selected' ? 'status' : command];
1319
+ if (!handler) {
1320
+ console.error(`Unknown command: ${command}`);
1321
+ console.error(`Available: ${listing.join(', ')}`);
1322
+ process.exit(1);
1323
+ }
1324
+
1325
+ const port = loadPort();
1326
+ const brokerSocketFile = brokerSocketPath(path.dirname(bridgeLockFile), port);
1327
+ const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
1328
+ const brokerAvailable = fs.existsSync(brokerSocketFile);
1329
+ if (!brokerAvailable) acquireBridgeLock();
1330
+
1331
+ function selectedTargetPin() {
1332
+ const platform = String(process.env.RECIPE_RN_EXPLICIT_PLATFORM || '').toLowerCase();
1333
+ const androidPin = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || '';
1334
+ const iosPin = process.env.IOS_SIMULATOR || '';
1335
+ return String(
1336
+ platform === 'android' ? androidPin : platform === 'ios' ? iosPin : androidPin || iosPin,
1337
+ ).trim();
1338
+ }
1339
+
1340
+ async function brokerTargets({ retainIdentity = false, includeGeneration = false } = {}) {
1341
+ const discoveryClient = await createBrokerClient(brokerSocketFile, '', timeout);
1342
+ try {
1343
+ const targets = await discoveryClient.control(
1344
+ retainIdentity
1345
+ ? 'resolve-targets'
1346
+ : includeGeneration
1347
+ ? 'list-target-identities'
1348
+ : 'list-targets',
1349
+ retainIdentity ? { nameIncludes: selectedTargetPin() } : {},
1350
+ timeout,
1351
+ );
1352
+ return (Array.isArray(targets) ? targets : []).flatMap((target) => {
1353
+ const deviceId = String(target?.deviceId || '');
1354
+ return deviceId
1355
+ ? [
1356
+ {
1357
+ wsUrl: deviceId,
1358
+ deviceName: String(target?.name || ''),
1359
+ generation: Number(target?.generation) || 0,
1360
+ },
1361
+ ]
1362
+ : [];
1363
+ });
1364
+ } finally {
1365
+ discoveryClient.close();
1366
+ }
1367
+ }
1368
+
1369
+ function selectBrokerTarget(targets) {
1370
+ const pin = selectedTargetPin();
1371
+ const candidates = pin
1372
+ ? targets.filter((target) => target.deviceName.toLowerCase().includes(pin.toLowerCase()))
1373
+ : targets;
1374
+ if (candidates.length !== 1) {
1375
+ const error = new Error(
1376
+ `Mobile CDP broker target selection requires one target; found ${candidates.length}${pin ? ` for ${JSON.stringify(pin)}` : ''}.`,
1377
+ );
1378
+ throw candidates.length === 0 ? coded(error, BRIDGE_ERROR_CODES.NO_TARGET) : error;
1379
+ }
1380
+ return candidates[0];
1381
+ }
1382
+
1383
+ if (command === 'target-identity-selected') {
1384
+ const target = brokerAvailable
1385
+ ? selectBrokerTarget(await brokerTargets({ includeGeneration: true }))
1386
+ : await discoverTarget(port, { probe: true });
1387
+ const generation = Number(target.generation) || 0;
1388
+ console.log(
1389
+ JSON.stringify({
1390
+ runtimeIdentity: generation ? `${target.wsUrl}:${generation}` : target.wsUrl,
1391
+ }),
1392
+ );
1393
+ return;
1394
+ }
1395
+
1396
+ async function clientFor(wsUrl) {
1397
+ if (brokerAvailable) {
1398
+ return createBrokerClient(brokerSocketFile, deviceIdFromUrl(wsUrl), timeout);
1399
+ }
1400
+ if (command.startsWith('network-capture-')) {
1401
+ throw new Error('Network capture requires a running CDP broker');
1402
+ }
1403
+ acquireBridgeLock();
1404
+ return createWSClient(wsUrl, timeout);
1405
+ }
1406
+
1407
+ // `status` probes ALL connected targets so both platforms are visible.
1408
+ // `status-selected` falls through to the normal pinned discovery path for
1409
+ // action code that must survive one device's Hermes runtime rotation.
1410
+ if (command === 'status') {
1411
+ const allTargets = brokerAvailable ? await brokerTargets() : await discoverAllTargets(port);
1412
+ const results = [];
1413
+ for (const target of allTargets) {
1414
+ let client;
1415
+ try {
1416
+ client = await clientFor(target.wsUrl);
1417
+ const platform = (await cdpEval(client, 'globalThis.__AGENTIC__?.platform')) || '';
1418
+ const result = await handler(client, args.slice(1), {
1419
+ deviceName: target.deviceName,
1420
+ platform,
1421
+ });
1422
+ results.push(result);
1423
+ } catch {
1424
+ // Recovery is correct: status probes every target advisory; an
1425
+ // unresponsive one degrades to absent-from-the-listing rather than
1426
+ // failing the whole multi-target command.
1427
+ } finally {
1428
+ if (client) client.close();
1429
+ }
1430
+ }
1431
+ console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
1432
+ return;
1433
+ }
1434
+
1435
+ const selectedTarget = brokerAvailable
1436
+ ? selectBrokerTarget(await brokerTargets({ retainIdentity: true }))
1437
+ : await discoverTarget(port, { probe: true });
1438
+ const { wsUrl, deviceName, generation } = selectedTarget;
1439
+ if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1440
+ const client = await clientFor(wsUrl);
1441
+
1442
+ try {
1443
+ const platform = command.endsWith('-deferred')
1444
+ ? ''
1445
+ : (await cdpEval(client, 'globalThis.__AGENTIC__?.platform')) || '';
1446
+ const runtimeIdentity =
1447
+ command === 'status-selected' ? (generation ? `${wsUrl}:${generation}` : wsUrl) : undefined;
1448
+ const result = await handler(client, args.slice(1), {
1449
+ deviceName,
1450
+ platform,
1451
+ runtimeIdentity,
1452
+ });
1453
+ if (brokerAvailable && command === 'status-selected') {
1454
+ const currentTarget = selectBrokerTarget(await brokerTargets({ includeGeneration: true }));
1455
+ if (currentTarget.wsUrl !== wsUrl || currentTarget.generation !== generation) {
1456
+ throw new Error('Hermes runtime rotated during status-selected');
1457
+ }
1458
+ }
1459
+ console.log(JSON.stringify(result, null, 2));
1460
+ } finally {
1461
+ client.close();
1462
+ }
1463
+ }
1464
+
1465
+ return main().catch((err) => {
1466
+ // Typed failure: a code stamped at the throw site wins; otherwise classify the
1467
+ // message here (still at the bridge, not by a needle far away in adapters.ts).
1468
+ // The marker + teaching go to stderr and the exit code carries the class, so a
1469
+ // caller recovers the code from either channel.
1470
+ const code = err && err.code ? err.code : classifyBridgeErrorMessage(err && err.message);
1471
+ if (code) {
1472
+ console.error(formatErrorMarker(code, err.message));
1473
+ if (teachingByErrorCode[code]) console.error(teachingByErrorCode[code]);
1474
+ process.exit(EXIT_CODE_BY_ERROR_CODE[code] || 1);
1475
+ }
1476
+ console.error(`ERROR: ${err.message}`);
1477
+ process.exit(1);
1478
+ });
1479
+ }
1480
+
1481
+ module.exports = { runBridgeCli };