@deeeed/metamask-harness 0.74.0 → 0.76.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 +39 -1
- package/README.md +1 -1
- package/adapters/shared/ensure-runner-deps.sh +15 -3
- package/adapters/shared/reap-checkout-metros.sh +9 -0
- package/dist/action-values.js +15 -0
- package/dist/adapters/core/actions.js +20 -0
- package/dist/adapters/core/live-script-env.js +36 -0
- package/dist/adapters/core/overlay.js +34 -0
- package/dist/adapters/core/readiness.js +8 -0
- package/dist/adapters/core/run.js +54 -0
- package/dist/adapters/core/surface.js +12 -1
- package/dist/adapters/extension/action-inputs.js +206 -0
- package/dist/adapters/extension/actions.js +51 -0
- package/dist/adapters/extension/console-capture.js +13 -1
- package/dist/adapters/extension/doctor-checks.js +135 -0
- package/dist/adapters/extension/fixtures.js +103 -0
- package/dist/adapters/extension/launch.js +315 -0
- package/dist/adapters/extension/launched-browser.js +51 -0
- package/dist/adapters/extension/overlay.js +55 -0
- package/dist/adapters/extension/readiness.js +32 -0
- package/dist/adapters/extension/reload.js +40 -0
- package/dist/adapters/extension/run.js +105 -0
- package/dist/adapters/extension/surface.js +31 -3
- package/dist/adapters/harness-freshness.js +4 -2
- package/dist/adapters/mobile/actions.js +54 -0
- package/dist/adapters/mobile/devices.js +21 -0
- package/dist/adapters/mobile/doctor-checks.js +40 -0
- package/dist/adapters/mobile/fixtures.js +141 -0
- package/dist/adapters/mobile/launch.js +192 -0
- package/dist/adapters/mobile/lifecycle.js +300 -0
- package/dist/adapters/mobile/overlay.js +27 -0
- package/dist/adapters/mobile/prepare-target.js +38 -0
- package/dist/adapters/mobile/readiness.js +39 -0
- package/dist/adapters/mobile/reload.js +15 -0
- package/dist/adapters/mobile/run.js +156 -0
- package/dist/adapters/mobile/runtime-identity.js +34 -0
- package/dist/adapters/mobile/surface.js +39 -3
- package/dist/adapters/mobile/ui.js +701 -0
- package/dist/adapters/performance/cdp-trace.js +24 -6
- package/dist/adapters/performance/device-platform.js +10 -0
- package/dist/adapters/registry.js +1 -1
- package/dist/adapters/terminal/actions.js +43 -0
- package/dist/adapters/terminal/console-capture.js +14 -1
- package/dist/adapters/terminal/doctor-checks.js +22 -0
- package/dist/adapters/terminal/overlay.js +18 -0
- package/dist/adapters/terminal/readiness.js +11 -0
- package/dist/adapters/terminal/run.js +30 -0
- package/dist/adapters/terminal/surface.js +21 -3
- package/dist/adapters.js +32 -824
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +3 -1
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +2 -5
- package/dist/commands/debug.js +6 -25
- package/dist/commands/device-target.js +2 -1
- package/dist/commands/doctor.js +26 -137
- package/dist/commands/fixtures.js +13 -217
- package/dist/commands/launch/device.js +2 -6
- package/dist/commands/launch/extension.js +3 -303
- package/dist/commands/launch/index.js +1 -1
- package/dist/commands/launch/mobile.js +2 -190
- package/dist/commands/launch/quote.js +7 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/parse-args.js +9 -4
- package/dist/commands/pin.js +226 -0
- package/dist/commands/prepare.js +23 -53
- package/dist/commands/provision.js +3 -7
- package/dist/commands/reload.js +8 -47
- package/dist/commands/run-engine.js +38 -138
- package/dist/commands/run-report.js +4 -48
- package/dist/commands/run.js +6 -48
- package/dist/commands/runtime-decision.js +5 -27
- package/dist/commands/self-test.js +1 -3
- package/dist/commands/status.js +20 -28
- package/dist/commands/stop.js +1 -1
- package/dist/doctor.js +12 -120
- package/dist/execution-provenance.js +3 -2
- package/dist/harness-pin.js +10 -0
- package/dist/harness.js +31 -100
- package/dist/heal-bounds.js +3 -2
- package/dist/live-adapter-contract.js +8 -34
- package/dist/manifest.js +3 -5
- package/dist/metamask-action-validation.js +5 -196
- package/dist/mm-harness-cli.js +30 -4
- package/dist/network-observation.js +10 -3
- package/dist/paths.js +8 -2
- package/dist/performance-observation.js +15 -22
- package/dist/pr-body/render.js +3 -1
- package/dist/recording-target.js +4 -39
- package/dist/run-diagnostics.js +21 -60
- package/dist/run-recording.js +5 -4
- package/dist/runner.js +20 -368
- package/dist/runtime-context.js +12 -17
- package/docs/CONTRIBUTING.md +29 -7
- package/package.json +2 -1
- package/scripts/completions.sh +2 -1
package/dist/adapters.js
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
3
|
import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
|
|
6
4
|
import {
|
|
7
5
|
runLiveAdapterScript
|
|
8
6
|
} from "./live-adapter-contract.js";
|
|
9
|
-
import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
|
|
10
|
-
import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
11
|
-
import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
|
|
12
|
-
import { nativeAgentDeviceStateDir } from "../library/actions/mobile/platform/native-session-name.mjs";
|
|
13
|
-
import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
14
7
|
import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
|
|
8
|
+
import { isRecord, traceText } from "./action-values.js";
|
|
9
|
+
import { getAdapterSurface } from "./adapters/surface.js";
|
|
15
10
|
import { handleRunNetworkAction } from "./network-observation.js";
|
|
16
11
|
import { handleRunPerformanceAction } from "./performance-observation.js";
|
|
17
|
-
const execFileAsync = promisify(execFile);
|
|
18
|
-
const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
19
|
-
"ui.press",
|
|
20
|
-
"ui.key_press",
|
|
21
|
-
"ui.set_input",
|
|
22
|
-
"ui.scroll",
|
|
23
|
-
"ui.swipe",
|
|
24
|
-
"ui.pan",
|
|
25
|
-
"ui.drag",
|
|
26
|
-
"ui.long_press",
|
|
27
|
-
"ui.wait_for",
|
|
28
|
-
"ui.screenshot",
|
|
29
|
-
"ui.capture_surface"
|
|
30
|
-
]);
|
|
31
|
-
function sleep(ms) {
|
|
32
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
-
}
|
|
34
12
|
function simpleAdapter(action, executor) {
|
|
35
13
|
return {
|
|
36
14
|
action,
|
|
@@ -93,9 +71,6 @@ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
93
71
|
"metamask.perps.teardown_state",
|
|
94
72
|
"metamask.perps.capture_performance"
|
|
95
73
|
]);
|
|
96
|
-
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
97
|
-
"metamask.perps.read_account"
|
|
98
|
-
]);
|
|
99
74
|
const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
|
|
100
75
|
"metamask.wallet.setup",
|
|
101
76
|
"metamask.wallet.import",
|
|
@@ -118,10 +93,10 @@ const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
118
93
|
"app.performance_assert"
|
|
119
94
|
]);
|
|
120
95
|
function requiresLiveAdapter(platform, action) {
|
|
121
|
-
return LIVE_ONLY_ACTIONS.has(action) || platform
|
|
96
|
+
return LIVE_ONLY_ACTIONS.has(action) || Boolean(getAdapterSurface(platform).actions.liveOnly?.includes(action)) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_FEATURE_FLAG_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
|
|
122
97
|
}
|
|
123
98
|
function liveRuntimeConfigured(platform, node) {
|
|
124
|
-
if (platform
|
|
99
|
+
if (!getAdapterSurface(platform).actions.liveNeedsCdpPort) return true;
|
|
125
100
|
return Boolean(node.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT);
|
|
126
101
|
}
|
|
127
102
|
async function runLiveFirst(platform, action, node, context, forceLive = false, preparedLiveAdapters) {
|
|
@@ -204,35 +179,6 @@ async function semanticResult(platform, action, node, context, forceLive = false
|
|
|
204
179
|
}
|
|
205
180
|
};
|
|
206
181
|
}
|
|
207
|
-
function isRecord(value) {
|
|
208
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
209
|
-
}
|
|
210
|
-
function optionalScalarText(value, label) {
|
|
211
|
-
if (value === void 0 || value === null) return void 0;
|
|
212
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
213
|
-
return String(value);
|
|
214
|
-
}
|
|
215
|
-
throw new Error(`${label} must be a string, number, or boolean.`);
|
|
216
|
-
}
|
|
217
|
-
function scalarText(value, label, fallback) {
|
|
218
|
-
return optionalScalarText(value, label) ?? fallback;
|
|
219
|
-
}
|
|
220
|
-
function firstScalarText(record, keys, label, fallback) {
|
|
221
|
-
for (const key of keys) {
|
|
222
|
-
const value = optionalScalarText(record[key], `${label}.${key}`);
|
|
223
|
-
if (value !== void 0) return value;
|
|
224
|
-
}
|
|
225
|
-
if (fallback !== void 0) return fallback;
|
|
226
|
-
throw new Error(`${label} requires one of: ${keys.join(", ")}.`);
|
|
227
|
-
}
|
|
228
|
-
function traceText(value) {
|
|
229
|
-
if (value === void 0 || value === null) return "";
|
|
230
|
-
if (value instanceof Error) return value.message;
|
|
231
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
232
|
-
return String(value);
|
|
233
|
-
}
|
|
234
|
-
return JSON.stringify(value);
|
|
235
|
-
}
|
|
236
182
|
function probeHttpJson(url, timeoutMs = 1e3) {
|
|
237
183
|
return new Promise((resolve) => {
|
|
238
184
|
const request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
@@ -280,67 +226,35 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
280
226
|
];
|
|
281
227
|
const bundledActions = [
|
|
282
228
|
...walletActions,
|
|
283
|
-
...platform !== "core" ? [
|
|
284
|
-
"app.network_capture",
|
|
285
|
-
"app.network_assert",
|
|
286
|
-
"app.performance_capture",
|
|
287
|
-
"app.performance_assert"
|
|
288
|
-
] : [],
|
|
289
229
|
"metamask.assets.read_visible_state",
|
|
290
230
|
"metamask.assets.open_details",
|
|
291
231
|
"metamask.assets.read_details",
|
|
292
232
|
"metamask.assets.verify_sorting",
|
|
293
233
|
"metamask.assets.set_token_visibility",
|
|
294
234
|
"metamask.assets.import_custom_token",
|
|
295
|
-
...platform === "extension" ? [
|
|
296
|
-
"metamask.assets.prepare_send",
|
|
297
|
-
"metamask.assets.review_send",
|
|
298
|
-
"metamask.assets.set_custom_gas",
|
|
299
|
-
"metamask.assets.select_network_scope"
|
|
300
|
-
] : [],
|
|
301
235
|
"metamask.wallet.lock",
|
|
302
236
|
"metamask.networks.read_visible_state",
|
|
303
237
|
"metamask.networks.add_custom",
|
|
304
|
-
...platform === "extension" ? ["metamask.networks.add_chainlist"] : [],
|
|
305
238
|
"metamask.networks.remove_custom",
|
|
306
239
|
"metamask.perps.read_visible_state",
|
|
307
240
|
"metamask.perps.search_markets",
|
|
308
241
|
"metamask.perps.set_market_favorite",
|
|
309
|
-
...platform === "extension" ? ["metamask.perps.compare_provider_market"] : [],
|
|
310
|
-
...platform === "extension" ? ["metamask.perps.read_eligibility"] : [],
|
|
311
|
-
...platform === "extension" ? [
|
|
312
|
-
"metamask.perps.open_position_action",
|
|
313
|
-
"metamask.perps.edit_margin",
|
|
314
|
-
"metamask.perps.update_position_tpsl",
|
|
315
|
-
"metamask.perps.assert_visible_consistency"
|
|
316
|
-
] : [],
|
|
317
242
|
"metamask.swap_bridge.read_visible_state",
|
|
318
243
|
"metamask.swap_bridge.select_assets",
|
|
319
244
|
"metamask.swap_bridge.set_amount",
|
|
320
245
|
"metamask.swap_bridge.set_max_amount",
|
|
321
246
|
"metamask.swap_bridge.set_slippage",
|
|
322
|
-
...platform === "mobile" ? ["metamask.swap_bridge.submit_transaction"] : [],
|
|
323
|
-
...platform === "extension" ? ["metamask.settings.set_basic_functionality"] : [],
|
|
324
247
|
"metamask.perps.read_positions",
|
|
325
248
|
"metamask.perps.ensure_positions",
|
|
326
249
|
"metamask.perps.assert_positions",
|
|
327
250
|
"metamask.perps.place_order",
|
|
328
|
-
...platform === "mobile" || platform === "core" ? ["metamask.perps.close_positions"] : [],
|
|
329
251
|
"metamask.perps.read_orders",
|
|
330
252
|
"metamask.perps.close_orders",
|
|
331
253
|
"metamask.perps.ensure_orders",
|
|
332
254
|
"metamask.perps.assert_orders",
|
|
333
|
-
...platform === "mobile" || platform === "core" ? ["metamask.perps.start_state"] : [],
|
|
334
255
|
"metamask.perps.teardown_state",
|
|
335
256
|
"metamask.perps.capture_performance",
|
|
336
|
-
...platform
|
|
337
|
-
...platform === "mobile" ? [
|
|
338
|
-
"metamask.perps.clear_performance_caches",
|
|
339
|
-
"metamask.perps.measure_homepage_visible",
|
|
340
|
-
"metamask.perps.prepare_local_snapshot_endpoint"
|
|
341
|
-
] : [],
|
|
342
|
-
// read_account is core-only: only the headless core adapter implements it.
|
|
343
|
-
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
257
|
+
...getAdapterSurface(platform).actions.semantic
|
|
344
258
|
];
|
|
345
259
|
const bundled = new Set(bundledActions);
|
|
346
260
|
const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
|
|
@@ -358,734 +272,41 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
358
272
|
)
|
|
359
273
|
);
|
|
360
274
|
}
|
|
361
|
-
function uiInputFor(context, input) {
|
|
362
|
-
return {
|
|
363
|
-
action: input.action,
|
|
364
|
-
node: input.node,
|
|
365
|
-
context: {
|
|
366
|
-
nodeId: context.nodeId,
|
|
367
|
-
projectRoot: context.projectRoot,
|
|
368
|
-
artifactsDir: context.artifactsDir,
|
|
369
|
-
env: context.env
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
function mobileProbeOutput(status, input, projectRoot) {
|
|
374
|
-
const entries = Array.isArray(status) ? status : [status];
|
|
375
|
-
const selected = selectBridgeStatusEntry(status, input) ?? null;
|
|
376
|
-
const route = isRecord(selected) && isRecord(selected.route) ? selected.route : null;
|
|
377
|
-
return {
|
|
378
|
-
reachable: isRecord(selected) && selected.agenticPresent === true && Boolean(route),
|
|
379
|
-
bridge: mobileBridgePath(projectRoot),
|
|
380
|
-
targetCount: entries.filter((entry) => isRecord(entry)).length,
|
|
381
|
-
deviceName: isRecord(selected) && typeof selected.deviceName === "string" ? selected.deviceName : null,
|
|
382
|
-
routeName: route && typeof route.name === "string" ? route.name : null,
|
|
383
|
-
accountPresent: isRecord(selected) && isRecord(selected.account)
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
function mobileBridgePath(_projectRoot) {
|
|
387
|
-
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
|
|
388
|
-
}
|
|
389
|
-
function resolveMobileWaitTarget(payload) {
|
|
390
|
-
const testId = optionalScalarText(
|
|
391
|
-
payload.test_id ?? payload.testID,
|
|
392
|
-
"Mobile ui.wait_for.test_id"
|
|
393
|
-
);
|
|
394
|
-
const expectedText = optionalScalarText(payload.text, "ui.wait_for.text");
|
|
395
|
-
if (!testId && !expectedText) {
|
|
396
|
-
throw new Error(
|
|
397
|
-
"Mobile ui.wait_for requires one of: test_id, testID, text."
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
const expected = scalarText(
|
|
401
|
-
payload.expected,
|
|
402
|
-
"ui.wait_for.expected",
|
|
403
|
-
"present"
|
|
404
|
-
).toLowerCase();
|
|
405
|
-
const textMatch = scalarText(
|
|
406
|
-
payload.text_match ?? payload.textMatch,
|
|
407
|
-
"ui.wait_for.text_match",
|
|
408
|
-
"contains"
|
|
409
|
-
).toLowerCase();
|
|
410
|
-
if (!["contains", "exact"].includes(textMatch)) {
|
|
411
|
-
throw new Error("ui.wait_for.text_match must be contains or exact.");
|
|
412
|
-
}
|
|
413
|
-
if (!testId && textMatch === "exact") {
|
|
414
|
-
throw new Error(
|
|
415
|
-
"Mobile text-only ui.wait_for supports text_match=contains; exact matching also requires test_id/testID."
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
return {
|
|
419
|
-
testId,
|
|
420
|
-
expectedText,
|
|
421
|
-
expected,
|
|
422
|
-
textMatch,
|
|
423
|
-
visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
|
|
427
|
-
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
428
|
-
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
429
|
-
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
430
|
-
MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
|
|
431
|
-
]);
|
|
432
|
-
function isRetryableMobileWaitError(error) {
|
|
433
|
-
return Boolean(
|
|
434
|
-
isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
async function waitForMobileTarget(input, payload) {
|
|
438
|
-
const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
|
|
439
|
-
const deadline = Date.now() + timeoutMs;
|
|
440
|
-
const { testId, expectedText, expected, textMatch, visibility } = resolveMobileWaitTarget(payload);
|
|
441
|
-
const query = {
|
|
442
|
-
...testId ? { testId } : {},
|
|
443
|
-
...expectedText && textMatch === "contains" ? { textContains: expectedText } : {},
|
|
444
|
-
visibility
|
|
445
|
-
};
|
|
446
|
-
const queryExpression = `(function(){
|
|
447
|
-
const api = globalThis.__AGENTIC__;
|
|
448
|
-
if (!api || typeof api.queryUiTarget !== 'function') {
|
|
449
|
-
return Promise.resolve(JSON.stringify({ unsupported: true }));
|
|
450
|
-
}
|
|
451
|
-
return api.queryUiTarget(${JSON.stringify(query)}).then(function(result){
|
|
452
|
-
return JSON.stringify(result);
|
|
453
|
-
});
|
|
454
|
-
})()`;
|
|
455
|
-
const textExpression = testId ? `(function(){
|
|
456
|
-
const api = globalThis.__AGENTIC__;
|
|
457
|
-
if (!api?.getTextByTestId) return null;
|
|
458
|
-
return api.getTextByTestId(${JSON.stringify(testId)});
|
|
459
|
-
})()` : null;
|
|
460
|
-
let lastResult = null;
|
|
461
|
-
let lastText = null;
|
|
462
|
-
let lastBridgeError = null;
|
|
463
|
-
const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
|
|
464
|
-
while (Date.now() <= deadline) {
|
|
465
|
-
const remainingMs = Math.max(1, deadline - Date.now());
|
|
466
|
-
const probeTimeoutMs = Math.min(5e3, remainingMs);
|
|
467
|
-
const probeInput = {
|
|
468
|
-
...input,
|
|
469
|
-
node: {
|
|
470
|
-
...input.node,
|
|
471
|
-
bridge_timeout_ms: probeTimeoutMs,
|
|
472
|
-
cdp_timeout_ms: probeTimeoutMs
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
let result;
|
|
476
|
-
try {
|
|
477
|
-
result = await evalAsync(probeInput, queryExpression);
|
|
478
|
-
} catch (error) {
|
|
479
|
-
if (!isRetryableMobileWaitError(error)) throw error;
|
|
480
|
-
lastBridgeError = error;
|
|
481
|
-
await sleep(250);
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
if (!isRecord(result) || result.unsupported === true) {
|
|
485
|
-
throw new Error(
|
|
486
|
-
"Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
lastResult = result;
|
|
490
|
-
const present = result.present === true;
|
|
491
|
-
const visible = result.visible === true;
|
|
492
|
-
let textMatched = !expectedText || textMatch === "contains";
|
|
493
|
-
if (expectedText && textMatch === "exact" && textExpression) {
|
|
494
|
-
try {
|
|
495
|
-
lastText = await evalSync(probeInput, textExpression);
|
|
496
|
-
} catch (error) {
|
|
497
|
-
if (!isRetryableMobileWaitError(error)) throw error;
|
|
498
|
-
lastBridgeError = error;
|
|
499
|
-
await sleep(250);
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
textMatched = traceText(lastText) === expectedText;
|
|
503
|
-
}
|
|
504
|
-
const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
|
|
505
|
-
if (matched) {
|
|
506
|
-
return {
|
|
507
|
-
matched: true,
|
|
508
|
-
...testId ? { testId } : {},
|
|
509
|
-
...expectedText ? { text: expectedText, textMatch } : {},
|
|
510
|
-
expected,
|
|
511
|
-
present,
|
|
512
|
-
visible,
|
|
513
|
-
visibility,
|
|
514
|
-
...isRecord(result.rect) ? { rect: result.rect } : {}
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
await sleep(250);
|
|
518
|
-
}
|
|
519
|
-
const target = [
|
|
520
|
-
testId ? `testID ${JSON.stringify(testId)}` : "",
|
|
521
|
-
expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
|
|
522
|
-
].filter(Boolean).join(" with ");
|
|
523
|
-
const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
|
|
524
|
-
const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
|
|
525
|
-
throw new Error(
|
|
526
|
-
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
const MOBILE_BRIDGE_HANDLERS = {
|
|
530
|
-
screenshot: handleMobileScreenshot,
|
|
531
|
-
status: handleMobileStatus,
|
|
532
|
-
navigate: handleMobileNavigate,
|
|
533
|
-
press: handleMobilePress,
|
|
534
|
-
keyPress: handleMobileKeyPress,
|
|
535
|
-
setInput: handleMobileSetInput,
|
|
536
|
-
scroll: handleMobileScroll,
|
|
537
|
-
waitFor: handleMobileWaitFor,
|
|
538
|
-
hud: handleMobileHud,
|
|
539
|
-
observeUi: handleMobileObserveUi
|
|
540
|
-
};
|
|
541
|
-
function createMetaMaskMobileBridge() {
|
|
542
|
-
return {
|
|
543
|
-
async send(command, context) {
|
|
544
|
-
const handler = MOBILE_BRIDGE_HANDLERS[command.command];
|
|
545
|
-
if (!handler) {
|
|
546
|
-
throw new Error(`React Native bridge command ${command.command} is not implemented by the MetaMask runner transport.`);
|
|
547
|
-
}
|
|
548
|
-
return handler(command.payload, context);
|
|
549
|
-
}
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
function mobileUiInput(context, command, payload) {
|
|
553
|
-
return uiInputFor(context, { action: `ui.${command}`, node: payload });
|
|
554
|
-
}
|
|
555
|
-
async function handleMobileScreenshot(payload, context) {
|
|
556
|
-
const input = mobileUiInput(context, "screenshot", payload);
|
|
557
|
-
const relPath = scalarText(payload.path, "ui.screenshot.path", `screenshots/${context.nodeId}.png`);
|
|
558
|
-
const artifact = await simulatorScreenshot(input, relPath);
|
|
559
|
-
context.registerArtifact(artifact);
|
|
560
|
-
return { captured: true, path: artifact.path, artifact };
|
|
561
|
-
}
|
|
562
|
-
async function handleMobileStatus(payload, context) {
|
|
563
|
-
return bridgeCommand(mobileUiInput(context, "status", payload), ["status"]);
|
|
564
|
-
}
|
|
565
|
-
async function handleMobileObserveUi(payload, context) {
|
|
566
|
-
return observeNativeUi(payload, context);
|
|
567
|
-
}
|
|
568
|
-
async function handleMobileNavigate(payload, context) {
|
|
569
|
-
const live = await runLiveAdapterScript({
|
|
570
|
-
platform: "mobile",
|
|
571
|
-
action: "ui.navigate",
|
|
572
|
-
node: payload,
|
|
573
|
-
context
|
|
574
|
-
});
|
|
575
|
-
if (!live) throw new Error("ui.navigate requires library/actions/mobile/ui/navigate.mjs.");
|
|
576
|
-
return isRecord(live.result) ? { ...live.result, liveAdapter: live.script } : live.result;
|
|
577
|
-
}
|
|
578
|
-
async function handleMobilePress(payload, context) {
|
|
579
|
-
const identifiers = ["test_id", "testID", "selector"].map((field) => optionalScalarText(payload[field], `ui.press.${field}`)).filter((value) => value !== void 0);
|
|
580
|
-
const text = optionalScalarText(payload.text, "ui.press.text");
|
|
581
|
-
if (identifiers.length + Number(text !== void 0) !== 1) {
|
|
582
|
-
throw new Error("ui.press requires exactly one of: test_id, testID, selector, text.");
|
|
583
|
-
}
|
|
584
|
-
const target = text ?? identifiers[0];
|
|
585
|
-
if (target.trim() === "") throw new Error("ui.press target must be non-empty.");
|
|
586
|
-
const longPress = payload.long_press === true;
|
|
587
|
-
if (longPress && text !== void 0) {
|
|
588
|
-
throw new Error("ui.press long_press=true requires test_id, testID, or selector targeting.");
|
|
589
|
-
}
|
|
590
|
-
const input = mobileUiInput(context, "press", payload);
|
|
591
|
-
if (text !== void 0) return bridgeCommand(input, ["press-text", target]);
|
|
592
|
-
return bridgeCommand(input, [longPress ? "long-press-test-id" : "press-test-id", target]);
|
|
593
|
-
}
|
|
594
|
-
async function handleMobileKeyPress(payload, context) {
|
|
595
|
-
const key = scalarText(payload.key, "ui.key_press.key", "Enter");
|
|
596
|
-
const input = mobileUiInput(context, "key_press", payload);
|
|
597
|
-
return bridgeCommand(input, ["key-press", key]);
|
|
598
|
-
}
|
|
599
|
-
async function handleMobileSetInput(payload, context) {
|
|
600
|
-
const input = mobileUiInput(context, "set_input", payload);
|
|
601
|
-
const testId = firstScalarText(payload, ["test_id", "testID"], "ui.set_input");
|
|
602
|
-
const value = scalarText(payload.value ?? payload.text, "ui.set_input.value", "");
|
|
603
|
-
const result = await bridgeCommand(input, ["set-input", testId, value]);
|
|
604
|
-
if (isRecord(result) && result.ok === false) {
|
|
605
|
-
throw new Error(`ui.set_input failed for mobile testID ${testId}: ${traceText(result.error ?? result)}`);
|
|
606
|
-
}
|
|
607
|
-
return isRecord(result) ? result : { result, testId, value };
|
|
608
|
-
}
|
|
609
|
-
async function handleMobileScroll(payload, context) {
|
|
610
|
-
const input = mobileUiInput(context, "scroll", payload);
|
|
611
|
-
const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
|
|
612
|
-
const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
|
|
613
|
-
const intoView = payload.scroll_into_view === true || payload.into_view === true;
|
|
614
|
-
const measureFromTestId = optionalScalarText(
|
|
615
|
-
payload.measure_from_test_id ?? payload.measureFromTestId,
|
|
616
|
-
"ui.scroll.measure_from_test_id"
|
|
617
|
-
);
|
|
618
|
-
const measureTargetTestId = optionalScalarText(
|
|
619
|
-
payload.measure_target_test_id ?? payload.measureTargetTestId,
|
|
620
|
-
"ui.scroll.measure_target_test_id"
|
|
621
|
-
);
|
|
622
|
-
const measureTargetText = optionalScalarText(
|
|
623
|
-
payload.measure_target_text ?? payload.measureTargetText,
|
|
624
|
-
"ui.scroll.measure_target_text"
|
|
625
|
-
);
|
|
626
|
-
const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
|
|
627
|
-
const measureRequiredPresentTestId = optionalScalarText(
|
|
628
|
-
payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
|
|
629
|
-
"ui.scroll.measure_required_present_test_id"
|
|
630
|
-
);
|
|
631
|
-
if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
|
|
632
|
-
throw new Error(
|
|
633
|
-
"ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
if (measureFromTestId && measureTargetCount === 1) {
|
|
637
|
-
if (intoView) {
|
|
638
|
-
throw new Error(
|
|
639
|
-
"ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
return measureMobileScrollTransition(input, {
|
|
643
|
-
animated: payload.animated === true,
|
|
644
|
-
fromTestId: measureFromTestId,
|
|
645
|
-
offset: Number(offset),
|
|
646
|
-
...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
|
|
647
|
-
...measureTargetText ? { targetText: measureTargetText } : {},
|
|
648
|
-
...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
|
|
649
|
-
timeoutMs: Math.max(
|
|
650
|
-
1,
|
|
651
|
-
Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
|
|
652
|
-
),
|
|
653
|
-
...testId ? { scrollTestId: testId } : {}
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
if (testId && intoView) {
|
|
657
|
-
const before = await queryMobileViewportTarget(input, testId);
|
|
658
|
-
if (before.visible === true) {
|
|
659
|
-
return {
|
|
660
|
-
ok: true,
|
|
661
|
-
testId,
|
|
662
|
-
intoView: true,
|
|
663
|
-
alreadyVisible: true,
|
|
664
|
-
rect: before.rect
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
const args = testId ? [
|
|
669
|
-
"scroll-view",
|
|
670
|
-
"--test-id",
|
|
671
|
-
testId,
|
|
672
|
-
"--offset",
|
|
673
|
-
offset,
|
|
674
|
-
...intoView ? ["--into-view"] : [],
|
|
675
|
-
animatedFlag(payload)
|
|
676
|
-
] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
|
|
677
|
-
const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
|
|
678
|
-
const deadline = Date.now() + timeoutMs;
|
|
679
|
-
let result;
|
|
680
|
-
while (true) {
|
|
681
|
-
try {
|
|
682
|
-
result = await bridgeCommand(input, args);
|
|
683
|
-
break;
|
|
684
|
-
} catch (error) {
|
|
685
|
-
if (!testId || Date.now() >= deadline) throw error;
|
|
686
|
-
await sleep(100);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
if (testId && intoView) {
|
|
690
|
-
const after = await waitForMobileViewportTarget(
|
|
691
|
-
input,
|
|
692
|
-
testId,
|
|
693
|
-
timeoutMs
|
|
694
|
-
);
|
|
695
|
-
if (after.visible !== true) {
|
|
696
|
-
throw new Error(
|
|
697
|
-
`ui.scroll did not bring mobile testID ${JSON.stringify(testId)} into the viewport; last result=${JSON.stringify(after)}.`
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
return {
|
|
702
|
-
...isRecord(result) ? result : { result },
|
|
703
|
-
intoView
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
async function measureMobileScrollTransition(input, options) {
|
|
707
|
-
const result = await bridgeCommand(
|
|
708
|
-
{
|
|
709
|
-
...input,
|
|
710
|
-
node: {
|
|
711
|
-
...input.node,
|
|
712
|
-
bridge_timeout_ms: options.timeoutMs + 5e3,
|
|
713
|
-
cdp_timeout_ms: options.timeoutMs + 5e3
|
|
714
|
-
}
|
|
715
|
-
},
|
|
716
|
-
["measure-scroll-transition", JSON.stringify(options)]
|
|
717
|
-
);
|
|
718
|
-
if (!isRecord(result) || result.unsupported === true) {
|
|
719
|
-
throw new Error(
|
|
720
|
-
"ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
|
|
721
|
-
);
|
|
722
|
-
}
|
|
723
|
-
if (result.ok !== true) {
|
|
724
|
-
throw new Error(
|
|
725
|
-
`ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
|
|
726
|
-
);
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
...result,
|
|
730
|
-
measureFromTestId: options.fromTestId,
|
|
731
|
-
...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
|
|
732
|
-
...options.targetText ? { measureTargetText: options.targetText } : {},
|
|
733
|
-
...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
async function queryMobileViewportTarget(input, testId) {
|
|
737
|
-
const expression = `(function(){
|
|
738
|
-
const api = globalThis.__AGENTIC__;
|
|
739
|
-
if (!api || typeof api.queryUiTarget !== 'function') {
|
|
740
|
-
return Promise.resolve(JSON.stringify({ unsupported: true }));
|
|
741
|
-
}
|
|
742
|
-
return api.queryUiTarget(${JSON.stringify({ testId, visibility: "viewport" })}).then(function(result){
|
|
743
|
-
return JSON.stringify(result);
|
|
744
|
-
});
|
|
745
|
-
})()`;
|
|
746
|
-
const result = await evalAsync(input, expression);
|
|
747
|
-
if (!isRecord(result) || result.unsupported === true) {
|
|
748
|
-
throw new Error(
|
|
749
|
-
"Mobile ui.scroll scroll_into_view requires the current __AGENTIC__.queryUiTarget bridge."
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
return result;
|
|
753
|
-
}
|
|
754
|
-
async function waitForMobileViewportTarget(input, testId, timeoutMs) {
|
|
755
|
-
const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 5e3));
|
|
756
|
-
let result = await queryMobileViewportTarget(input, testId);
|
|
757
|
-
while (result.visible !== true && Date.now() < deadline) {
|
|
758
|
-
await sleep(100);
|
|
759
|
-
result = await queryMobileViewportTarget(input, testId);
|
|
760
|
-
}
|
|
761
|
-
return result;
|
|
762
|
-
}
|
|
763
|
-
async function handleMobileWaitFor(payload, context) {
|
|
764
|
-
return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
|
|
765
|
-
}
|
|
766
|
-
async function handleMobileHud(payload, context) {
|
|
767
|
-
const automaticProgress = typeof payload.action_name === "string" || context.nodeId === "recipe-complete" && isRecord(payload.progress) && payload.progress.complete === true;
|
|
768
|
-
const captureProgress = payload.action_name === "ui.screenshot" || payload.action_name === "ui.capture_surface";
|
|
769
|
-
const input = mobileUiInput(
|
|
770
|
-
context,
|
|
771
|
-
"hud",
|
|
772
|
-
automaticProgress ? {
|
|
773
|
-
...payload,
|
|
774
|
-
bridge_timeout_ms: captureProgress ? 1e4 : 2e3,
|
|
775
|
-
cdp_timeout_ms: captureProgress ? 1e4 : 2e3
|
|
776
|
-
} : payload
|
|
777
|
-
);
|
|
778
|
-
if (payload.clear === true) {
|
|
779
|
-
try {
|
|
780
|
-
return await bridgeCommand(input, ["hide-step"]);
|
|
781
|
-
} catch (error) {
|
|
782
|
-
return mobileHudSkippedOrThrow(error);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
const hud = mobileHudPayload(payload, context);
|
|
786
|
-
try {
|
|
787
|
-
const result = await bridgeCommand(input, [
|
|
788
|
-
automaticProgress && !captureProgress ? "show-step-json-deferred" : "show-step-json",
|
|
789
|
-
JSON.stringify(hud.step)
|
|
790
|
-
]);
|
|
791
|
-
return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
|
|
792
|
-
} catch (error) {
|
|
793
|
-
return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
async function hideMobileHudOnTeardown(projectRoot, env = {}) {
|
|
797
|
-
if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") return;
|
|
798
|
-
if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
|
|
799
|
-
const input = {
|
|
800
|
-
node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
|
|
801
|
-
context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
|
|
802
|
-
};
|
|
803
|
-
try {
|
|
804
|
-
await bridgeCommand(input, ["hide-step-deferred"]);
|
|
805
|
-
} catch {
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
function mobileHudSkippedOrThrow(error, extra = {}) {
|
|
809
|
-
const reason = traceText(error);
|
|
810
|
-
const code = isRecord(error) && typeof error.code === "string" ? error.code : void 0;
|
|
811
|
-
if (isMobileHudLifecycleSkip(reason, code)) {
|
|
812
|
-
process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
|
|
813
|
-
`);
|
|
814
|
-
return {
|
|
815
|
-
hud: false,
|
|
816
|
-
skipped: true,
|
|
817
|
-
warning: "app.hud skipped because the mobile bridge target is down during lifecycle transition.",
|
|
818
|
-
...extra,
|
|
819
|
-
reason
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
|
|
823
|
-
}
|
|
824
|
-
const HUD_LIFECYCLE_SKIP_CODES = /* @__PURE__ */ new Set([
|
|
825
|
-
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
826
|
-
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
827
|
-
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
828
|
-
MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
|
|
829
|
-
]);
|
|
830
|
-
function isMobileHudLifecycleSkip(reason, code) {
|
|
831
|
-
if (code && HUD_LIFECYCLE_SKIP_CODES.has(code)) return true;
|
|
832
|
-
const haystack = reason.toLowerCase();
|
|
833
|
-
return [
|
|
834
|
-
"no responding bridge target",
|
|
835
|
-
"pinned android device",
|
|
836
|
-
"bridge probe failed",
|
|
837
|
-
"econnrefused",
|
|
838
|
-
"timed out",
|
|
839
|
-
"no react native bridge target",
|
|
840
|
-
// Metro down mid-run: HUD cannot paint, so skip rather than abort (the real
|
|
841
|
-
// action fails on its own). Matches the METRO_UNREACHABLE code branch above.
|
|
842
|
-
"cannot reach metro",
|
|
843
|
-
// Dev-client foreground/reload kills the Hermes page mid-flight: pending
|
|
844
|
-
// CDP calls surface as a message timeout or a closed socket.
|
|
845
|
-
"cdp message timeout",
|
|
846
|
-
"websocket closed"
|
|
847
|
-
].some((needle) => haystack.includes(needle));
|
|
848
|
-
}
|
|
849
|
-
function animatedFlag(payload) {
|
|
850
|
-
return payload.animated === true ? "--animated" : "--no-animated";
|
|
851
|
-
}
|
|
852
|
-
function mobileHudPayload(payload, context) {
|
|
853
|
-
const nodeId = scalarText(payload.node_id ?? payload.nodeId, "app.hud.node_id", context.nodeId);
|
|
854
|
-
const status = scalarText(payload.status, "app.hud.status", "running");
|
|
855
|
-
const text = optionalScalarText(payload.intent ?? payload.text ?? payload.detail, "app.hud.intent");
|
|
856
|
-
if (!text || !text.trim()) {
|
|
857
|
-
throw new Error("app.hud requires intent/text/detail; automatic recipe progress should provide node intent.");
|
|
858
|
-
}
|
|
859
|
-
const detail = scalarText(payload.detail, "app.hud.detail", "");
|
|
860
|
-
const error = scalarText(payload.error, "app.hud.error", "");
|
|
861
|
-
const progress = mobileHudProgress(payload.progress);
|
|
862
|
-
const display = isRecord(payload.display) ? payload.display : {};
|
|
863
|
-
const showDebug = display.showDebug === true;
|
|
864
|
-
const showDetail = display.showDetail === true;
|
|
865
|
-
const proofTarget = payload.proofTarget ?? payload.proof_target;
|
|
866
|
-
const displayId = [mobileHudStatusLabel(status), progress.text].filter(Boolean).join(" ");
|
|
867
|
-
return {
|
|
868
|
-
nodeId,
|
|
869
|
-
status,
|
|
870
|
-
step: {
|
|
871
|
-
id: displayId || nodeId,
|
|
872
|
-
nodeId,
|
|
873
|
-
status,
|
|
874
|
-
intent: text,
|
|
875
|
-
progress: progress.value,
|
|
876
|
-
detail: showDetail && detail !== text ? detail : void 0,
|
|
877
|
-
error: error || void 0,
|
|
878
|
-
debug: showDebug ? {
|
|
879
|
-
nodeId,
|
|
880
|
-
proofTarget
|
|
881
|
-
} : void 0
|
|
882
|
-
}
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
function mobileHudProgress(progress) {
|
|
886
|
-
if (!isRecord(progress)) return { text: "" };
|
|
887
|
-
if (typeof progress.current !== "number" || typeof progress.total !== "number") return { text: "" };
|
|
888
|
-
return {
|
|
889
|
-
text: `${progress.current}/${progress.total}`,
|
|
890
|
-
value: { current: progress.current, total: progress.total }
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
function mobileHudStatusLabel(status) {
|
|
894
|
-
if (status === "fail") return "fail";
|
|
895
|
-
if (status === "pass") return "pass";
|
|
896
|
-
return "run";
|
|
897
|
-
}
|
|
898
275
|
function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters, { hudPolicy = "auto" } = {}) {
|
|
899
|
-
|
|
276
|
+
const ui = getAdapterSurface(platform).actions.ui;
|
|
277
|
+
if (!ui) {
|
|
900
278
|
return {
|
|
901
279
|
async execute(action) {
|
|
902
280
|
throw new Error(
|
|
903
|
-
|
|
281
|
+
`${platform} adapter is headless and cannot execute UI action ${action}. Next: mm-harness actions --matrix`
|
|
904
282
|
);
|
|
905
283
|
}
|
|
906
284
|
};
|
|
907
285
|
}
|
|
908
|
-
const base
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
artifactsDir: input.context.artifactsDir
|
|
919
|
-
}
|
|
920
|
-
},
|
|
921
|
-
callback
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
}) : harness.createCdpWebUiTransport({
|
|
925
|
-
async withPage(input, callback) {
|
|
926
|
-
return withExtensionPage(
|
|
927
|
-
{
|
|
928
|
-
action: input.action,
|
|
929
|
-
node: input.node,
|
|
930
|
-
context: {
|
|
931
|
-
nodeId: input.context.nodeId,
|
|
932
|
-
projectRoot: input.context.projectRoot,
|
|
933
|
-
artifactsDir: input.context.artifactsDir
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
callback
|
|
286
|
+
const { base, execute } = ui(harness, { hudPolicy });
|
|
287
|
+
async function standard(action, node, context) {
|
|
288
|
+
if (action === "ui.navigate") {
|
|
289
|
+
const live = await runLiveFirst(
|
|
290
|
+
platform,
|
|
291
|
+
action,
|
|
292
|
+
node,
|
|
293
|
+
context,
|
|
294
|
+
false,
|
|
295
|
+
preparedLiveAdapters
|
|
937
296
|
);
|
|
297
|
+
if (live) return live.output;
|
|
298
|
+
throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
|
|
938
299
|
}
|
|
939
|
-
|
|
300
|
+
return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
|
|
301
|
+
}
|
|
940
302
|
return {
|
|
941
303
|
...base.observe ? { observe: base.observe.bind(base) } : {},
|
|
942
304
|
async execute(action, node, context) {
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
return showTerminalHud(
|
|
946
|
-
{ action, node, context: { nodeId: context.nodeId, projectRoot: context.projectRoot } },
|
|
947
|
-
{ hudPolicy }
|
|
948
|
-
);
|
|
949
|
-
}
|
|
950
|
-
if (action === "ui.navigate") {
|
|
951
|
-
const live = await runLiveFirst(
|
|
952
|
-
platform,
|
|
953
|
-
action,
|
|
954
|
-
node,
|
|
955
|
-
context,
|
|
956
|
-
false,
|
|
957
|
-
preparedLiveAdapters
|
|
958
|
-
);
|
|
959
|
-
if (live) return live.output;
|
|
960
|
-
throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
|
|
961
|
-
}
|
|
962
|
-
if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action) && (context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || ["ui.swipe", "ui.pan", "ui.drag", "ui.long_press", "ui.capture_surface"].includes(action))) {
|
|
963
|
-
return executeNativeProviderAction(action, node, context, harness.createNativeUiTransport);
|
|
964
|
-
}
|
|
965
|
-
return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
|
|
966
|
-
}
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
async function executeNativeProviderAction(action, node, context, createTransport) {
|
|
970
|
-
if (!createTransport) {
|
|
971
|
-
throw new Error(
|
|
972
|
-
`${action} requires the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
const platform = mobileNativePlatform(context.env);
|
|
976
|
-
const device = platform === "android" ? context.env.ADB_SERIAL ?? context.env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : context.env.SIM_UDID ?? context.env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
|
|
977
|
-
if (!device) {
|
|
978
|
-
throw new Error(
|
|
979
|
-
`${action} requires one selected ${platform} device. Next: mm-harness status --adapter mobile --all-devices --json`
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
if (platform === "android" && action === "ui.swipe" && isCoordinateTarget(node.target)) {
|
|
983
|
-
return executeAndroidCoordinateSwipe(node, String(device));
|
|
984
|
-
}
|
|
985
|
-
const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
|
|
986
|
-
const session = (platform === "ios" ? `mm-harness-${process.pid}-${device}` : `mm-harness-${process.pid}-${context.nodeId}`).replace(/[^a-zA-Z0-9._-]/gu, "-");
|
|
987
|
-
const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
|
|
988
|
-
let transport;
|
|
989
|
-
try {
|
|
990
|
-
transport = await createTransport({ platform, device, app, session, stateDir });
|
|
991
|
-
} catch (error) {
|
|
992
|
-
throw new Error(
|
|
993
|
-
`${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
|
|
994
|
-
{ cause: error }
|
|
995
|
-
);
|
|
996
|
-
}
|
|
997
|
-
let executionError;
|
|
998
|
-
let output;
|
|
999
|
-
try {
|
|
1000
|
-
output = await transport.execute(action, node, context);
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1003
|
-
executionError = /^Native recipe actions require the optional agent-device provider:/u.test(message) ? new Error(
|
|
1004
|
-
`${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
|
|
1005
|
-
{ cause: error }
|
|
1006
|
-
) : error;
|
|
1007
|
-
}
|
|
1008
|
-
let closeError;
|
|
1009
|
-
try {
|
|
1010
|
-
await transport.close?.();
|
|
1011
|
-
} catch (error) {
|
|
1012
|
-
closeError = error;
|
|
1013
|
-
}
|
|
1014
|
-
if (executionError && closeError) {
|
|
1015
|
-
const message = executionError instanceof Error ? executionError.message : String(executionError);
|
|
1016
|
-
throw new AggregateError([executionError, closeError], message);
|
|
1017
|
-
}
|
|
1018
|
-
if (executionError) throw executionError;
|
|
1019
|
-
if (closeError) throw closeError;
|
|
1020
|
-
return output;
|
|
1021
|
-
}
|
|
1022
|
-
function isCoordinateTarget(value) {
|
|
1023
|
-
return isRecord(value) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y);
|
|
1024
|
-
}
|
|
1025
|
-
async function executeAndroidCoordinateSwipe(node, device) {
|
|
1026
|
-
const target = node.target;
|
|
1027
|
-
const direction = String(node.direction ?? "").toLowerCase();
|
|
1028
|
-
if (!["up", "down", "left", "right"].includes(direction)) {
|
|
1029
|
-
throw new Error("ui.swipe.direction must be up, down, left, or right.");
|
|
1030
|
-
}
|
|
1031
|
-
const distance = Number(node.distance ?? 300);
|
|
1032
|
-
const durationMs = Number(node.duration_ms ?? 300);
|
|
1033
|
-
if (!Number.isFinite(distance) || distance <= 0) {
|
|
1034
|
-
throw new Error("ui.swipe.distance must be a positive number.");
|
|
1035
|
-
}
|
|
1036
|
-
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
1037
|
-
throw new Error("ui.swipe.duration_ms must be a positive number.");
|
|
1038
|
-
}
|
|
1039
|
-
const start = { x: Math.round(target.x), y: Math.round(target.y) };
|
|
1040
|
-
const end = {
|
|
1041
|
-
x: Math.round(start.x + (direction === "left" ? -distance : direction === "right" ? distance : 0)),
|
|
1042
|
-
y: Math.round(start.y + (direction === "up" ? -distance : direction === "down" ? distance : 0))
|
|
1043
|
-
};
|
|
1044
|
-
const adbPath = resolveMobileToolPath("adb", { required: true });
|
|
1045
|
-
try {
|
|
1046
|
-
await execFileAsync(
|
|
1047
|
-
adbPath,
|
|
1048
|
-
[
|
|
1049
|
-
"-s",
|
|
1050
|
-
device,
|
|
1051
|
-
"shell",
|
|
1052
|
-
"input",
|
|
1053
|
-
"swipe",
|
|
1054
|
-
String(start.x),
|
|
1055
|
-
String(start.y),
|
|
1056
|
-
String(end.x),
|
|
1057
|
-
String(end.y),
|
|
1058
|
-
String(Math.round(durationMs))
|
|
1059
|
-
],
|
|
1060
|
-
{ encoding: "utf8", timeout: Math.round(durationMs) + 1e4 }
|
|
1061
|
-
);
|
|
1062
|
-
} catch (error) {
|
|
1063
|
-
throw new Error(
|
|
1064
|
-
`Android ui.swipe failed on ${device}: ${error instanceof Error ? error.message : String(error)}`
|
|
1065
|
-
);
|
|
1066
|
-
}
|
|
1067
|
-
const settleMs = node.settle === false ? 0 : 300;
|
|
1068
|
-
if (settleMs > 0) await sleep(settleMs);
|
|
1069
|
-
return {
|
|
1070
|
-
action: "ui.swipe",
|
|
1071
|
-
resolvedStart: start,
|
|
1072
|
-
resolvedEnd: end,
|
|
1073
|
-
backend: "adb-input-swipe",
|
|
1074
|
-
segments: 1,
|
|
1075
|
-
settlement: {
|
|
1076
|
-
method: "bounded-post-gesture",
|
|
1077
|
-
waitedMs: settleMs,
|
|
1078
|
-
lifecycleNeutral: true
|
|
305
|
+
if (execute) return execute(action, node, context, () => standard(action, node, context));
|
|
306
|
+
return standard(action, node, context);
|
|
1079
307
|
}
|
|
1080
308
|
};
|
|
1081
309
|
}
|
|
1082
|
-
function mobileNativePlatform(env) {
|
|
1083
|
-
if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
|
|
1084
|
-
if (env.ADB_SERIAL || env.ANDROID_SERIAL) return "android";
|
|
1085
|
-
if (env.SIM_UDID || env.IOS_SIMULATOR) return "ios";
|
|
1086
|
-
if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") return process.env.PLATFORM;
|
|
1087
|
-
return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
|
|
1088
|
-
}
|
|
1089
310
|
function normalizeUiWaitNode(node) {
|
|
1090
311
|
if (node.expected !== void 0 || typeof node.visible !== "boolean") return node;
|
|
1091
312
|
return { ...node, expected: node.visible ? "visible" : "absent" };
|
|
@@ -1106,17 +327,13 @@ function createMetaMaskAdapters(adapter, declaredCustomActions = [], preparedLiv
|
|
|
1106
327
|
const metroPort = node.metro_port ?? node.watcher_port ?? process.env.WATCHER_PORT ?? null;
|
|
1107
328
|
const timeoutMs = Number(node.probe_timeout_ms ?? node.cdp_timeout_ms ?? node.timeout_ms ?? process.env.CDP_TIMEOUT ?? 1e4);
|
|
1108
329
|
const required = node.require_reachable === true || node.required === true;
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
const
|
|
1112
|
-
const probe2 = mobileProbeOutput(status, uiInput, context.projectRoot);
|
|
1113
|
-
if (!probe2.reachable) {
|
|
1114
|
-
throw new Error("cdp.target required a reachable mobile React Native bridge, but bridge status did not expose a selected app route.");
|
|
1115
|
-
}
|
|
330
|
+
const target = getAdapterSurface(platform).actions.cdpTarget;
|
|
331
|
+
if (required && target.requiredProbe) {
|
|
332
|
+
const probe2 = await target.requiredProbe(node, context);
|
|
1116
333
|
return {
|
|
1117
334
|
output: {
|
|
1118
335
|
platform,
|
|
1119
|
-
transport:
|
|
336
|
+
transport: target.transport,
|
|
1120
337
|
cdpPort,
|
|
1121
338
|
metroPort,
|
|
1122
339
|
timeoutMs,
|
|
@@ -1124,8 +341,8 @@ function createMetaMaskAdapters(adapter, declaredCustomActions = [], preparedLiv
|
|
|
1124
341
|
}
|
|
1125
342
|
};
|
|
1126
343
|
}
|
|
1127
|
-
const targetPort =
|
|
1128
|
-
const url =
|
|
344
|
+
const targetPort = target.watcherPortFirst ? metroPort ?? cdpPort : cdpPort;
|
|
345
|
+
const url = targetPort ? `http://127.0.0.1:${traceText(targetPort)}/${target.probePath}` : null;
|
|
1129
346
|
const probe = url ? await probeHttpJson(url, timeoutMs) : { reachable: null, statusCode: null, error: "no port declared" };
|
|
1130
347
|
if (required && !probe.reachable) {
|
|
1131
348
|
throw new Error(`cdp.target required a reachable ${platform} runtime at ${url ?? "<no port>"}: ${traceText(probe.error) || "not reachable"}`);
|
|
@@ -1133,7 +350,7 @@ function createMetaMaskAdapters(adapter, declaredCustomActions = [], preparedLiv
|
|
|
1133
350
|
return {
|
|
1134
351
|
output: {
|
|
1135
352
|
platform,
|
|
1136
|
-
transport:
|
|
353
|
+
transport: target.transport,
|
|
1137
354
|
cdpPort,
|
|
1138
355
|
metroPort,
|
|
1139
356
|
probeUrl: url,
|
|
@@ -1146,17 +363,8 @@ function createMetaMaskAdapters(adapter, declaredCustomActions = [], preparedLiv
|
|
|
1146
363
|
];
|
|
1147
364
|
return adapters;
|
|
1148
365
|
}
|
|
1149
|
-
function targetProbeUrl(platform, targetPort) {
|
|
1150
|
-
if (!targetPort) return null;
|
|
1151
|
-
const pathSuffix = platform === "mobile" ? "json/list" : "json/version";
|
|
1152
|
-
return `http://127.0.0.1:${traceText(targetPort)}/${pathSuffix}`;
|
|
1153
|
-
}
|
|
1154
366
|
export {
|
|
1155
367
|
createMetaMaskAdapters,
|
|
1156
368
|
createMetaMaskUiTransport,
|
|
1157
|
-
|
|
1158
|
-
isMobileHudLifecycleSkip,
|
|
1159
|
-
isRetryableMobileWaitError,
|
|
1160
|
-
normalizeUiWaitNode,
|
|
1161
|
-
resolveMobileWaitTarget
|
|
369
|
+
normalizeUiWaitNode
|
|
1162
370
|
};
|