@deeeed/metamask-harness 0.43.0 → 0.44.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.
- package/CHANGELOG.md +25 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +304 -34
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +20 -8
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +102 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
- package/adapters/mobile/reload-app.mjs +99 -1
- package/dist/adapters/mobile/prepare.js +12 -0
- package/dist/adapters.js +22 -3
- package/dist/commands/call.js +40 -2
- package/dist/commands/run-engine.js +247 -139
- package/dist/commands/run-report.js +68 -0
- package/dist/commands/run.js +47 -2
- package/dist/execution-provenance.js +342 -0
- package/dist/run-diagnostics.js +36 -11
- package/dist/runner.js +44 -13
- package/library/actions/mobile/perps/performance-capture.mjs +570 -189
- package/library/actions/mobile/perps/perps.mjs +122 -0
- package/library/actions/mobile/platform/bridge.mjs +40 -8
- package/library/actions/mobile/platform/native-session.mjs +1 -1
- package/library/actions/mobile/wallet/lock.mjs +1 -4
- package/library/actions/mobile/wallet/select_account.mjs +129 -17
- package/library/manifests/mobile.action-manifest.json +28 -3
- package/library/recipes/mobile/perps/performance.recipe.json +73 -47
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.44.0 - 2026-08-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Remove `cold_disk_cache` from the `perps.performance` lifecycle enum because disk hydration is a cache source within a lifecycle, not a standalone lifecycle.
|
|
10
|
+
- Await promise-returning Mobile `press-test-id` and `set-input` bridge handlers so recipe nodes finish only after the in-app operation settles.
|
|
11
|
+
- Keep Mobile broker commands available while Hermes installs the in-app bridge after a target handoff, instead of reconnecting the inspector until actions time out.
|
|
12
|
+
- Honor `launch --build` when a healthy Mobile runtime is already connected; the explicit native build can no longer return a false pass without invoking the build leaf.
|
|
13
|
+
- Let resident Homepage and short-resume performance recipes finish from the ordered visible-content sequence without requiring a new live takeover.
|
|
14
|
+
- Make Mobile reload stay on the pinned device, fail before an active Android Hermes sampler can abort the app, and fail if the process exits or a replacement target never appears; reload never relaunches the app.
|
|
15
|
+
- Bind run evidence to the executable Harness source, build, bin, package, library, and adapter inputs plus task-local command/config helpers, and reject input drift or symlink swaps.
|
|
16
|
+
- Persist recovery and mutation lists in recipe packages, include a redacted run-scoped application log, reject failed recovery before retrying, and prove lifecycle continuity with native process IDs, including opaque runtimes when requested.
|
|
17
|
+
- Refuse stale Mobile source under every heal policy unless an authored `app.lifecycle restart` reloads it, and invalidate run evidence if recipe, library, or product provenance drifts after preparation.
|
|
18
|
+
- Require Homepage demand, committed UI, resolved content, and live-current frame records before the canonical Mobile Perps performance recipe can pass.
|
|
19
|
+
- Fail a Mobile recipe or action without implicitly restarting Metro or the app; process restarts remain explicit lifecycle actions.
|
|
20
|
+
- Keep one Mobile inspector connection open while React Native replays its console buffer, avoiding repeated `Runtime.enable` requests that can stall Metro after unlock.
|
|
21
|
+
- Allow `metamask.wallet.lock` to use the same visible Android controls on development and opaque clients.
|
|
22
|
+
- Keep account-selection proof attached across the temporary Hermes/CDP stall caused by the account switch itself.
|
|
23
|
+
- Add `metamask.perps.ensure_mode` so recipes can reach Lite or Pro through the visible mode control without inheriting prior device state.
|
|
24
|
+
- Preserve Homepage boundary source and content-variant metadata in performance summaries, and bind recipe manifests to the exact product checkout commit.
|
|
25
|
+
- Accept only structured, generation-coherent Perps live-stream records as performance proof, and keep Mobile wallet and mode probes inside one absolute action deadline.
|
|
26
|
+
- Keep the latest automatic Mobile HUD update in the CDP broker and retry it until the in-app bridge applies it when the pinned Hermes target returns, without restarting the app.
|
|
27
|
+
- Keep the Perps performance recipe's empty-account setup read-only and allow its setup-only prime capture to contain no performance records.
|
|
28
|
+
- Redact quoted JSON credential fields before persisting run-scoped application logs.
|
|
29
|
+
|
|
5
30
|
## 0.43.0 - 2026-08-21
|
|
6
31
|
|
|
7
32
|
### Added
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const path = require('node:path');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
21
22
|
const { loadPort } = require('./lib/config.cjs');
|
|
22
23
|
const {
|
|
23
24
|
discoverTarget,
|
|
@@ -36,6 +37,126 @@ const {
|
|
|
36
37
|
} = require('./lib/bridge-errors.cjs');
|
|
37
38
|
const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
|
|
38
39
|
const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
|
|
40
|
+
const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
|
|
41
|
+
const bridge = this.__AGENTIC__;
|
|
42
|
+
if (step === null) {
|
|
43
|
+
if (typeof bridge?.hideStep !== 'function') return false;
|
|
44
|
+
setTimeout(() => bridge.hideStep(), 0);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (typeof bridge?.showStep !== 'function') return false;
|
|
48
|
+
setTimeout(() => bridge.showStep(step), 0);
|
|
49
|
+
return true;
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
function mobileToolPath(tool) {
|
|
53
|
+
return process.env[`MM_HARNESS_${tool.toUpperCase()}_PATH`] || tool;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bootedIosDevice(deviceName) {
|
|
57
|
+
if (!deviceName) return null;
|
|
58
|
+
try {
|
|
59
|
+
const devices = JSON.parse(
|
|
60
|
+
execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const device = Object.values(devices.devices || {})
|
|
65
|
+
.flat()
|
|
66
|
+
.find(
|
|
67
|
+
(candidate) =>
|
|
68
|
+
(candidate?.name === deviceName || candidate?.udid === deviceName) &&
|
|
69
|
+
candidate?.state === 'Booted',
|
|
70
|
+
);
|
|
71
|
+
return device?.udid ? device : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tapVisibleIosAccessibilityTarget(testId, deviceName, platform) {
|
|
78
|
+
if (platform !== 'ios') return null;
|
|
79
|
+
const device = bootedIosDevice(deviceName);
|
|
80
|
+
if (!device) return null;
|
|
81
|
+
try {
|
|
82
|
+
const idb = mobileToolPath('idb');
|
|
83
|
+
const elements = JSON.parse(
|
|
84
|
+
execFileSync(
|
|
85
|
+
idb,
|
|
86
|
+
['ui', 'describe-all', '--udid', device.udid, '--json'],
|
|
87
|
+
{ encoding: 'utf8' },
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
const windowFrame = elements
|
|
91
|
+
.map((element) => element?.frame)
|
|
92
|
+
.filter(
|
|
93
|
+
(frame) =>
|
|
94
|
+
frame &&
|
|
95
|
+
frame.x === 0 &&
|
|
96
|
+
frame.y === 0 &&
|
|
97
|
+
frame.width > 0 &&
|
|
98
|
+
frame.height > 0,
|
|
99
|
+
)
|
|
100
|
+
.sort(
|
|
101
|
+
(first, second) =>
|
|
102
|
+
second.width * second.height - first.width * first.height,
|
|
103
|
+
)[0];
|
|
104
|
+
if (!windowFrame) return null;
|
|
105
|
+
const target = elements.find((element) => {
|
|
106
|
+
const frame = element?.frame;
|
|
107
|
+
return (
|
|
108
|
+
element?.AXUniqueId === testId &&
|
|
109
|
+
element?.enabled !== false &&
|
|
110
|
+
element?.hittable !== false &&
|
|
111
|
+
frame &&
|
|
112
|
+
frame.width > 0 &&
|
|
113
|
+
frame.height > 0 &&
|
|
114
|
+
frame.x >= 0 &&
|
|
115
|
+
frame.y >= 0 &&
|
|
116
|
+
frame.x + frame.width <= windowFrame.width &&
|
|
117
|
+
frame.y + frame.height <= windowFrame.height
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
if (!target) return null;
|
|
121
|
+
const x = Math.round(target.frame.x + target.frame.width / 2);
|
|
122
|
+
const y = Math.round(target.frame.y + target.frame.height / 2);
|
|
123
|
+
execFileSync(
|
|
124
|
+
idb,
|
|
125
|
+
['ui', 'tap', String(x), String(y), '--udid', device.udid],
|
|
126
|
+
{ encoding: 'utf8' },
|
|
127
|
+
);
|
|
128
|
+
return { ok: true, testId, deviceName, provider: 'idb-accessibility' };
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function applyHudUpdate(client, step) {
|
|
135
|
+
const globalObject = await client.send('Runtime.evaluate', {
|
|
136
|
+
expression: 'globalThis',
|
|
137
|
+
returnByValue: false,
|
|
138
|
+
awaitPromise: false,
|
|
139
|
+
});
|
|
140
|
+
const objectId = globalObject?.result?.objectId;
|
|
141
|
+
if (!objectId) {
|
|
142
|
+
throw new Error('Mobile HUD runtime did not expose globalThis');
|
|
143
|
+
}
|
|
144
|
+
const result = await client.send('Runtime.callFunctionOn', {
|
|
145
|
+
objectId,
|
|
146
|
+
functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
|
|
147
|
+
arguments: [{ value: step }],
|
|
148
|
+
returnByValue: true,
|
|
149
|
+
awaitPromise: false,
|
|
150
|
+
});
|
|
151
|
+
if (result?.exceptionDetails) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
result.exceptionDetails.exception?.description ||
|
|
154
|
+
result.exceptionDetails.text ||
|
|
155
|
+
'Mobile HUD update failed',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return result?.result?.value === true;
|
|
159
|
+
}
|
|
39
160
|
|
|
40
161
|
function parsePerformanceConsoleEvent(params) {
|
|
41
162
|
const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
|
|
@@ -97,7 +218,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
|
|
|
97
218
|
throw new Error('Usage: set-input <testId> <value>');
|
|
98
219
|
}
|
|
99
220
|
const expr = `(function() {
|
|
100
|
-
if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
|
|
221
|
+
if (globalThis.__AGENTIC__?.setInput) return Promise.resolve(globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}));
|
|
101
222
|
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
102
223
|
if (!hook) return { ok: false, error: 'No React DevTools hook' };
|
|
103
224
|
var renderers = hook.renderers;
|
|
@@ -133,7 +254,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
|
|
|
133
254
|
})()`;
|
|
134
255
|
let result;
|
|
135
256
|
try {
|
|
136
|
-
result = await
|
|
257
|
+
result = await cdpEvalAsync(client, expr);
|
|
137
258
|
} catch (error) {
|
|
138
259
|
if (redact) {
|
|
139
260
|
throw new Error(`Secret input could not be applied for testID ${testId}.`);
|
|
@@ -151,6 +272,26 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
|
|
|
151
272
|
};
|
|
152
273
|
}
|
|
153
274
|
|
|
275
|
+
function parseHudStep(args) {
|
|
276
|
+
const raw = args.join(' ');
|
|
277
|
+
let step;
|
|
278
|
+
try {
|
|
279
|
+
step = JSON.parse(raw);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
|
|
282
|
+
}
|
|
283
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
284
|
+
throw new Error('show-step-json requires a JSON object step payload');
|
|
285
|
+
}
|
|
286
|
+
if (typeof step.intent !== 'string' || !step.intent.trim()) {
|
|
287
|
+
throw new Error('show-step-json requires step.intent');
|
|
288
|
+
}
|
|
289
|
+
if (step.id !== undefined && typeof step.id !== 'string') {
|
|
290
|
+
throw new Error('show-step-json step.id must be a string when provided');
|
|
291
|
+
}
|
|
292
|
+
return step;
|
|
293
|
+
}
|
|
294
|
+
|
|
154
295
|
const COMMANDS = {
|
|
155
296
|
async navigate(client, args, { deviceName, platform } = {}) {
|
|
156
297
|
const routeName = ROUTE_ALIASES[args[0]] || args[0];
|
|
@@ -300,14 +441,15 @@ const COMMANDS = {
|
|
|
300
441
|
return await cdpEval(client, `globalThis.__AGENTIC__?.switchAccount(${JSON.stringify(address)})`);
|
|
301
442
|
},
|
|
302
443
|
|
|
303
|
-
async 'press-test-id'(client, args, { deviceName } = {}) {
|
|
444
|
+
async 'press-test-id'(client, args, { deviceName, platform } = {}) {
|
|
304
445
|
const testId = args[0];
|
|
305
446
|
if (!testId) {
|
|
306
447
|
throw new Error('Usage: press-test-id <testId>');
|
|
307
448
|
}
|
|
308
|
-
// Try
|
|
449
|
+
// Try the app bridge first. Native accessibility remains an iOS fallback
|
|
450
|
+
// for controls whose React handler cannot be invoked directly.
|
|
309
451
|
const expr = `(function() {
|
|
310
|
-
if (globalThis.__AGENTIC__?.pressTestId) return globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)});
|
|
452
|
+
if (globalThis.__AGENTIC__?.pressTestId) return Promise.resolve(globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)}));
|
|
311
453
|
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
312
454
|
if (!hook) return { ok: false, error: 'No React DevTools hook' };
|
|
313
455
|
var renderers = hook.renderers;
|
|
@@ -330,10 +472,72 @@ const COMMANDS = {
|
|
|
330
472
|
}
|
|
331
473
|
return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onPress' };
|
|
332
474
|
})()`;
|
|
333
|
-
|
|
475
|
+
let result;
|
|
476
|
+
let bridgeError;
|
|
477
|
+
try {
|
|
478
|
+
result = await cdpEvalAsync(client, expr);
|
|
479
|
+
if (result?.ok !== false) {
|
|
480
|
+
return { ...result, testId, deviceName };
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
bridgeError = error;
|
|
484
|
+
}
|
|
485
|
+
const nativeResult = tapVisibleIosAccessibilityTarget(
|
|
486
|
+
testId,
|
|
487
|
+
deviceName,
|
|
488
|
+
platform,
|
|
489
|
+
);
|
|
490
|
+
if (nativeResult) return nativeResult;
|
|
491
|
+
if (bridgeError) throw bridgeError;
|
|
334
492
|
return { ...result, testId, deviceName };
|
|
335
493
|
},
|
|
336
494
|
|
|
495
|
+
async 'key-press'(_client, args, { deviceName, platform } = {}) {
|
|
496
|
+
const requestedKey = String(args[0] || 'Enter').toLowerCase();
|
|
497
|
+
const isEnter = requestedKey === 'enter' || requestedKey === 'return';
|
|
498
|
+
const isBack = requestedKey === 'escape' || requestedKey === 'back';
|
|
499
|
+
if (!isEnter && !isBack) {
|
|
500
|
+
throw new Error(`Unsupported Mobile key ${JSON.stringify(args[0])}`);
|
|
501
|
+
}
|
|
502
|
+
if (platform === 'android') {
|
|
503
|
+
const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
|
|
504
|
+
if (!serial) throw new Error('key-press requires a selected Android device');
|
|
505
|
+
const keyCode = isEnter ? '66' : '4';
|
|
506
|
+
execFileSync(
|
|
507
|
+
mobileToolPath('adb'),
|
|
508
|
+
['-s', serial, 'shell', 'input', 'keyevent', keyCode],
|
|
509
|
+
{ encoding: 'utf8' },
|
|
510
|
+
);
|
|
511
|
+
return {
|
|
512
|
+
ok: true,
|
|
513
|
+
key: args[0] || 'Enter',
|
|
514
|
+
keyCode,
|
|
515
|
+
deviceName,
|
|
516
|
+
provider: 'adb-key',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (platform !== 'ios') {
|
|
520
|
+
throw new Error(`key-press requires a selected Mobile platform`);
|
|
521
|
+
}
|
|
522
|
+
const device = bootedIosDevice(deviceName);
|
|
523
|
+
if (!device) {
|
|
524
|
+
throw new Error(`No booted simulator named ${JSON.stringify(deviceName)}`);
|
|
525
|
+
}
|
|
526
|
+
const keyCode = isEnter ? '40' : '41';
|
|
527
|
+
execFileSync(
|
|
528
|
+
mobileToolPath('idb'),
|
|
529
|
+
['ui', 'key', keyCode, '--udid', device.udid],
|
|
530
|
+
{ encoding: 'utf8' },
|
|
531
|
+
);
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
key: args[0] || 'Enter',
|
|
535
|
+
keyCode,
|
|
536
|
+
deviceName,
|
|
537
|
+
provider: 'idb-key',
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
|
|
337
541
|
async 'long-press-test-id'(client, args, { deviceName } = {}) {
|
|
338
542
|
const testId = args[0];
|
|
339
543
|
if (!testId) {
|
|
@@ -406,6 +610,9 @@ const COMMANDS = {
|
|
|
406
610
|
// selector fiber. For into-view requests the scroll container is commonly
|
|
407
611
|
// an ancestor (for example, a Wallet Home section row), so use the
|
|
408
612
|
// ancestor-aware bridge fallback below instead.
|
|
613
|
+
if (${intoView} && globalThis.__AGENTIC__?.scrollIntoView) {
|
|
614
|
+
return globalThis.__AGENTIC__.scrollIntoView(${JSON.stringify(testId)}, ${animated});
|
|
615
|
+
}
|
|
409
616
|
if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
|
|
410
617
|
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
411
618
|
if (!hook) return { ok: false, error: 'No React DevTools hook' };
|
|
@@ -427,14 +634,56 @@ const COMMANDS = {
|
|
|
427
634
|
}
|
|
428
635
|
return false;
|
|
429
636
|
}
|
|
637
|
+
function findMeasurable(fiber) {
|
|
638
|
+
if (!fiber) return null;
|
|
639
|
+
var sn = fiber.stateNode;
|
|
640
|
+
if (sn && typeof sn.measureLayout === 'function') return sn;
|
|
641
|
+
return findMeasurable(fiber.child);
|
|
642
|
+
}
|
|
430
643
|
function tryScrollNear(anchor) {
|
|
431
644
|
if (!opts.intoView && tryScroll(anchor, false)) return true;
|
|
645
|
+
var target = opts.intoView ? findMeasurable(anchor) : null;
|
|
432
646
|
var current = anchor ? anchor.return : null;
|
|
433
647
|
while (current) {
|
|
434
648
|
var sn = current.stateNode;
|
|
649
|
+
var currentProps = current.memoizedProps;
|
|
435
650
|
if (sn) {
|
|
436
|
-
if (typeof sn.scrollTo === 'function'
|
|
437
|
-
|
|
651
|
+
if (typeof sn.scrollTo === 'function' && !(currentProps && currentProps.horizontal === true)) {
|
|
652
|
+
if (target) {
|
|
653
|
+
return new Promise(function(resolve) {
|
|
654
|
+
target.measureLayout(
|
|
655
|
+
sn,
|
|
656
|
+
function(x, y) {
|
|
657
|
+
sn.scrollTo({ y: y, animated: opts.animated });
|
|
658
|
+
resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
|
|
659
|
+
},
|
|
660
|
+
function() {
|
|
661
|
+
resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
|
|
662
|
+
}
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
sn.scrollTo({ y: opts.offset, animated: opts.animated });
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
if (typeof sn.scrollToOffset === 'function' && !(currentProps && currentProps.horizontal === true)) {
|
|
670
|
+
if (target) {
|
|
671
|
+
return new Promise(function(resolve) {
|
|
672
|
+
target.measureLayout(
|
|
673
|
+
sn,
|
|
674
|
+
function(x, y) {
|
|
675
|
+
sn.scrollToOffset({ offset: y, animated: opts.animated });
|
|
676
|
+
resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
|
|
677
|
+
},
|
|
678
|
+
function() {
|
|
679
|
+
resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
sn.scrollToOffset({ offset: opts.offset, animated: opts.animated });
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
438
687
|
}
|
|
439
688
|
current = current.return;
|
|
440
689
|
}
|
|
@@ -443,7 +692,23 @@ const COMMANDS = {
|
|
|
443
692
|
function findTestId(fiber) {
|
|
444
693
|
if (!fiber) return null;
|
|
445
694
|
var props = fiber.memoizedProps;
|
|
446
|
-
if (props && props.testID === opts.testId)
|
|
695
|
+
if (props && props.testID === opts.testId) {
|
|
696
|
+
var ancestor = fiber;
|
|
697
|
+
var inactive = false;
|
|
698
|
+
while (ancestor) {
|
|
699
|
+
var ancestorProps = ancestor.memoizedProps;
|
|
700
|
+
var ancestorStyle = ancestorProps && ancestorProps.style;
|
|
701
|
+
if (
|
|
702
|
+
(ancestorProps && ancestorProps.activityState === 0) ||
|
|
703
|
+
(ancestorStyle && ancestorStyle.display === 'none')
|
|
704
|
+
) {
|
|
705
|
+
inactive = true;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
ancestor = ancestor.return;
|
|
709
|
+
}
|
|
710
|
+
if (!inactive) return fiber;
|
|
711
|
+
}
|
|
447
712
|
return findTestId(fiber.child) || findTestId(fiber.sibling);
|
|
448
713
|
}
|
|
449
714
|
for (var [id] of renderers) {
|
|
@@ -459,11 +724,16 @@ const COMMANDS = {
|
|
|
459
724
|
scrolled = tryScroll(r.current);
|
|
460
725
|
}
|
|
461
726
|
});
|
|
462
|
-
if (scrolled)
|
|
727
|
+
if (scrolled) {
|
|
728
|
+
if (typeof scrolled.then === 'function') return scrolled;
|
|
729
|
+
return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
|
|
730
|
+
}
|
|
463
731
|
}
|
|
464
732
|
return { ok: false, error: opts.testId ? 'No scrollable near testID=' + opts.testId : 'No scrollable found' };
|
|
465
733
|
})()`;
|
|
466
|
-
const result =
|
|
734
|
+
const result = intoView
|
|
735
|
+
? await cdpEvalAsync(client, expr)
|
|
736
|
+
: await cdpEval(client, expr);
|
|
467
737
|
return { ...result, deviceName };
|
|
468
738
|
},
|
|
469
739
|
|
|
@@ -841,34 +1111,33 @@ const COMMANDS = {
|
|
|
841
1111
|
},
|
|
842
1112
|
|
|
843
1113
|
async 'show-step-json'(client, args) {
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
try {
|
|
847
|
-
step = JSON.parse(raw);
|
|
848
|
-
} catch (error) {
|
|
849
|
-
throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
|
|
850
|
-
}
|
|
851
|
-
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
852
|
-
throw new Error('show-step-json requires a JSON object step payload');
|
|
853
|
-
}
|
|
854
|
-
if (typeof step.intent !== 'string' || !step.intent.trim()) {
|
|
855
|
-
throw new Error('show-step-json requires step.intent');
|
|
856
|
-
}
|
|
857
|
-
// HUD derives status/progress from step.id when status is omitted; a
|
|
858
|
-
// non-string id makes statusForStep throw during render. Reject at the boundary.
|
|
859
|
-
if (step.id !== undefined && typeof step.id !== 'string') {
|
|
860
|
-
throw new Error('show-step-json step.id must be a string when provided');
|
|
861
|
-
}
|
|
862
|
-
const payload = JSON.stringify(step);
|
|
863
|
-
await cdpEval(client, `globalThis.__AGENTIC__?.showStep && globalThis.__AGENTIC__.showStep(${payload})`);
|
|
1114
|
+
const step = parseHudStep(args);
|
|
1115
|
+
await applyHudUpdate(client, step);
|
|
864
1116
|
return { ok: true };
|
|
865
1117
|
},
|
|
866
1118
|
|
|
1119
|
+
async 'show-step-json-deferred'(client, args) {
|
|
1120
|
+
const step = parseHudStep(args);
|
|
1121
|
+
if (typeof client.control === 'function') {
|
|
1122
|
+
return client.control('hud-update', { step });
|
|
1123
|
+
}
|
|
1124
|
+
await applyHudUpdate(client, step);
|
|
1125
|
+
return { ok: true, status: 'applied' };
|
|
1126
|
+
},
|
|
1127
|
+
|
|
867
1128
|
async 'hide-step'(client) {
|
|
868
|
-
await
|
|
1129
|
+
await applyHudUpdate(client, null);
|
|
869
1130
|
return { ok: true };
|
|
870
1131
|
},
|
|
871
1132
|
|
|
1133
|
+
async 'hide-step-deferred'(client) {
|
|
1134
|
+
if (typeof client.control === 'function') {
|
|
1135
|
+
return client.control('hud-update', { step: null });
|
|
1136
|
+
}
|
|
1137
|
+
await applyHudUpdate(client, null);
|
|
1138
|
+
return { ok: true, status: 'applied' };
|
|
1139
|
+
},
|
|
1140
|
+
|
|
872
1141
|
async 'profiler-start'(client) {
|
|
873
1142
|
// Hermes CDP exposes the sampling profiler via the Profiler domain.
|
|
874
1143
|
// Output of Profiler.stop is a Chrome-compatible .cpuprofile object.
|
|
@@ -1170,8 +1439,9 @@ Environment:
|
|
|
1170
1439
|
const client = await clientFor(wsUrl);
|
|
1171
1440
|
|
|
1172
1441
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1442
|
+
const platform = command.endsWith('-deferred')
|
|
1443
|
+
? ''
|
|
1444
|
+
: await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
|
|
1175
1445
|
const result = await handler(client, args.slice(1), { deviceName, platform });
|
|
1176
1446
|
console.log(JSON.stringify(result, null, 2));
|
|
1177
1447
|
} finally {
|
|
@@ -40,6 +40,7 @@ const { resolvePort } = require('./lib/config.cjs');
|
|
|
40
40
|
// Hermes uses Node's built-in WebSocket client. The local DevTools proxy uses
|
|
41
41
|
// the package's pinned `ws` server implementation.
|
|
42
42
|
const HANDSHAKE_TIMEOUT_MS = 3000;
|
|
43
|
+
const RUNTIME_ENABLE_TIMEOUT_MS = 60_000;
|
|
43
44
|
|
|
44
45
|
const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
|
|
45
46
|
const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
|
|
@@ -311,25 +312,36 @@ function connect(target) {
|
|
|
311
312
|
ws.addEventListener('open', async () => {
|
|
312
313
|
clearTimeout(handshakeTimer);
|
|
313
314
|
session.opened = true;
|
|
315
|
+
// A live inspector socket is enough for broker commands. Do not keep the
|
|
316
|
+
// broker unavailable while Runtime.enable replays a large console buffer or
|
|
317
|
+
// while __AGENTIC__ is being installed after a Hermes context handoff.
|
|
318
|
+
// Commands such as status already report agenticPresent=false and their
|
|
319
|
+
// callers retry against the same bounded action deadline.
|
|
320
|
+
session.brokerReady = true;
|
|
321
|
+
broker.onSessionOpen(deviceId);
|
|
322
|
+
devtoolsProxy.onSessionOpen(deviceId, session);
|
|
323
|
+
process.stderr.write(`console-forwarder: attached ${name}\n`);
|
|
314
324
|
try {
|
|
315
|
-
await sendCommand(
|
|
325
|
+
await sendCommand(
|
|
326
|
+
session,
|
|
327
|
+
'Runtime.enable',
|
|
328
|
+
{},
|
|
329
|
+
RUNTIME_ENABLE_TIMEOUT_MS,
|
|
330
|
+
);
|
|
316
331
|
const evaluation = await sendCommand(session, 'Runtime.evaluate', {
|
|
317
332
|
expression: "typeof globalThis.__AGENTIC__ === 'object'",
|
|
318
333
|
returnByValue: true,
|
|
319
334
|
awaitPromise: false,
|
|
320
335
|
});
|
|
321
336
|
if (evaluation?.result?.value !== true) {
|
|
322
|
-
|
|
337
|
+
process.stderr.write(
|
|
338
|
+
`console-forwarder: ${name} is waiting for __AGENTIC__\n`,
|
|
339
|
+
);
|
|
323
340
|
}
|
|
324
|
-
session.brokerReady = true;
|
|
325
|
-
broker.onSessionOpen(deviceId);
|
|
326
|
-
devtoolsProxy.onSessionOpen(deviceId, session);
|
|
327
|
-
process.stderr.write(`console-forwarder: attached ${name}\n`);
|
|
328
341
|
} catch (error) {
|
|
329
342
|
process.stderr.write(
|
|
330
|
-
`console-forwarder:
|
|
343
|
+
`console-forwarder: console bootstrap pending for ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
|
|
331
344
|
);
|
|
332
|
-
ws.close();
|
|
333
345
|
}
|
|
334
346
|
});
|
|
335
347
|
ws.addEventListener('message', (event) => {
|
|
@@ -14,12 +14,25 @@ const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
|
14
14
|
const MAX_POST_DATA_BYTES = 64 * 1024;
|
|
15
15
|
const MAX_REQUEST_FRAME_BYTES = 8 * 1024 * 1024;
|
|
16
16
|
const MAX_RESPONSE_FRAME_BYTES = 16 * 1024 * 1024;
|
|
17
|
+
const MAX_HUD_STEP_BYTES = 16 * 1024;
|
|
18
|
+
const MAX_HUD_UPDATES = 64;
|
|
17
19
|
const MAX_RETAINED_STRING_LENGTH = 256;
|
|
18
20
|
const MAX_BROKER_TIMEOUT_MS = 60_000;
|
|
19
21
|
const SENSITIVE_FIELD =
|
|
20
22
|
/(?:address|authorization|cookie|key|password|secret|token|user|account)/iu;
|
|
21
23
|
const SENSITIVE_VALUE =
|
|
22
24
|
/(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
|
|
25
|
+
const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
|
|
26
|
+
const bridge = this.__AGENTIC__;
|
|
27
|
+
if (step === null) {
|
|
28
|
+
if (typeof bridge?.hideStep !== 'function') return false;
|
|
29
|
+
setTimeout(() => bridge.hideStep(), 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (typeof bridge?.showStep !== 'function') return false;
|
|
33
|
+
setTimeout(() => bridge.showStep(step), 0);
|
|
34
|
+
return true;
|
|
35
|
+
}`;
|
|
23
36
|
|
|
24
37
|
function brokerSocketPath(runtimeDir, endpointIdentity) {
|
|
25
38
|
const runtimePath = path.resolve(
|
|
@@ -353,6 +366,9 @@ function createCdpBroker({
|
|
|
353
366
|
}) {
|
|
354
367
|
const captures = new Map();
|
|
355
368
|
const completedCaptures = new Map();
|
|
369
|
+
const hudUpdates = new Map();
|
|
370
|
+
const appliedHudVersions = new Map();
|
|
371
|
+
const hudApplyChains = new Map();
|
|
356
372
|
const clients = new Set();
|
|
357
373
|
const subscriptions = new Map();
|
|
358
374
|
const knownTargets = new Map(
|
|
@@ -482,6 +498,67 @@ function createCdpBroker({
|
|
|
482
498
|
capturesFor(deviceId).delete(capture.id);
|
|
483
499
|
}
|
|
484
500
|
|
|
501
|
+
async function applyHudUpdate(deviceId, timeoutMs, socket) {
|
|
502
|
+
if (!hudUpdates.has(deviceId)) return { status: 'none' };
|
|
503
|
+
const budgetMs = boundedTimeout(timeoutMs);
|
|
504
|
+
const deadline = Date.now() + budgetMs;
|
|
505
|
+
const session = await waitForSession(deviceId, budgetMs, socket);
|
|
506
|
+
const update = hudUpdates.get(deviceId);
|
|
507
|
+
if (!update) return { status: 'none' };
|
|
508
|
+
if (appliedHudVersions.get(deviceId) === update.version) {
|
|
509
|
+
return { status: 'applied', version: update.version };
|
|
510
|
+
}
|
|
511
|
+
let applied = false;
|
|
512
|
+
while (Date.now() < deadline) {
|
|
513
|
+
const globalObject = await sendCommand(
|
|
514
|
+
session,
|
|
515
|
+
'Runtime.evaluate',
|
|
516
|
+
{ expression: 'globalThis', returnByValue: false, awaitPromise: false },
|
|
517
|
+
Math.max(1, deadline - Date.now()),
|
|
518
|
+
);
|
|
519
|
+
const objectId = globalObject?.result?.objectId;
|
|
520
|
+
if (!objectId) {
|
|
521
|
+
throw new Error('Mobile HUD runtime did not expose globalThis');
|
|
522
|
+
}
|
|
523
|
+
const evaluation = await sendCommand(
|
|
524
|
+
session,
|
|
525
|
+
'Runtime.callFunctionOn',
|
|
526
|
+
{
|
|
527
|
+
objectId,
|
|
528
|
+
functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
|
|
529
|
+
arguments: [{ value: update.step }],
|
|
530
|
+
returnByValue: true,
|
|
531
|
+
awaitPromise: false,
|
|
532
|
+
},
|
|
533
|
+
Math.max(1, deadline - Date.now()),
|
|
534
|
+
);
|
|
535
|
+
if (evaluation?.result?.value === true) {
|
|
536
|
+
applied = true;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
540
|
+
}
|
|
541
|
+
if (!applied) {
|
|
542
|
+
throw new Error('Mobile HUD bridge was not installed before the update deadline');
|
|
543
|
+
}
|
|
544
|
+
if (hudUpdates.get(deviceId)?.version === update.version) {
|
|
545
|
+
appliedHudVersions.set(deviceId, update.version);
|
|
546
|
+
}
|
|
547
|
+
return { status: 'applied', version: update.version };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function scheduleHudUpdate(deviceId, timeoutMs, socket) {
|
|
551
|
+
const previous = hudApplyChains.get(deviceId) || Promise.resolve();
|
|
552
|
+
const next = previous
|
|
553
|
+
.catch(() => undefined)
|
|
554
|
+
.then(() => applyHudUpdate(deviceId, timeoutMs, socket));
|
|
555
|
+
hudApplyChains.set(deviceId, next);
|
|
556
|
+
void next.finally(() => {
|
|
557
|
+
if (hudApplyChains.get(deviceId) === next) hudApplyChains.delete(deviceId);
|
|
558
|
+
}).catch(() => undefined);
|
|
559
|
+
return next;
|
|
560
|
+
}
|
|
561
|
+
|
|
485
562
|
function expireCapture(deviceId, capture) {
|
|
486
563
|
if (capturesFor(deviceId).get(capture.id) !== capture) return;
|
|
487
564
|
finishCapture(deviceId, capture, true);
|
|
@@ -539,6 +616,29 @@ function createCdpBroker({
|
|
|
539
616
|
if (action === 'resolve-targets') {
|
|
540
617
|
return resolveTargets(params, timeoutMs, socket);
|
|
541
618
|
}
|
|
619
|
+
if (action === 'hud-update') {
|
|
620
|
+
const step = params.step ?? null;
|
|
621
|
+
if (step !== null && (typeof step !== 'object' || Array.isArray(step))) {
|
|
622
|
+
throw new Error('HUD update requires an object step or null');
|
|
623
|
+
}
|
|
624
|
+
if (Buffer.byteLength(JSON.stringify(step)) > MAX_HUD_STEP_BYTES) {
|
|
625
|
+
throw new Error('HUD update exceeds its bound');
|
|
626
|
+
}
|
|
627
|
+
const previousVersion = hudUpdates.get(deviceId)?.version || 0;
|
|
628
|
+
if (hudUpdates.has(deviceId)) {
|
|
629
|
+
hudUpdates.delete(deviceId);
|
|
630
|
+
}
|
|
631
|
+
while (hudUpdates.size >= MAX_HUD_UPDATES) {
|
|
632
|
+
const oldestDeviceId = hudUpdates.keys().next().value;
|
|
633
|
+
hudUpdates.delete(oldestDeviceId);
|
|
634
|
+
appliedHudVersions.delete(oldestDeviceId);
|
|
635
|
+
}
|
|
636
|
+
const version = previousVersion + 1;
|
|
637
|
+
hudUpdates.set(deviceId, { step, version });
|
|
638
|
+
requestDiscovery?.(deviceId);
|
|
639
|
+
void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
|
|
640
|
+
return { status: 'queued', version };
|
|
641
|
+
}
|
|
542
642
|
if (action === 'capture-start') {
|
|
543
643
|
const capture = normalizeCapture(params);
|
|
544
644
|
const deviceCaptures = capturesFor(deviceId);
|
|
@@ -726,11 +826,13 @@ function createCdpBroker({
|
|
|
726
826
|
if (capture.reconnects > 0) capture.partial = true;
|
|
727
827
|
void enableNetwork(deviceId, capture);
|
|
728
828
|
}
|
|
829
|
+
void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
|
|
729
830
|
},
|
|
730
831
|
onSessionClose(deviceId) {
|
|
731
832
|
const previous = knownTargets.get(deviceId);
|
|
732
833
|
if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
|
|
733
834
|
observedSessions.delete(deviceId);
|
|
835
|
+
appliedHudVersions.delete(deviceId);
|
|
734
836
|
for (const capture of capturesFor(deviceId).values()) {
|
|
735
837
|
capture.reconnects += 1;
|
|
736
838
|
capture.partial = true;
|