@deeeed/metamask-harness 0.33.2 → 0.34.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 +31 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +83 -23
- package/dist/adapters/mobile/prepare.js +14 -0
- package/dist/adapters/mobile/video-recorder.js +282 -0
- package/dist/adapters.js +225 -35
- package/dist/command-contract.js +1 -0
- package/dist/commands/device-target.js +17 -6
- package/dist/commands/launch/index.js +27 -5
- package/dist/commands/launch/mobile.js +9 -2
- package/dist/commands/run-engine.js +34 -2
- package/dist/devices.js +9 -2
- package/dist/heal-bounds.js +10 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/recipe-security.js +1 -0
- package/dist/recording-target.js +23 -5
- package/dist/runner.js +107 -6
- package/dist/runtime-context.js +33 -1
- package/docs/RECIPES.md +41 -0
- package/library/actions/mobile/app/network-control.mjs +135 -0
- package/library/actions/mobile/app/network.mjs +4 -0
- package/library/actions/mobile/perps/capture_performance.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +383 -0
- package/library/actions/mobile/perps/perps.mjs +16 -0
- package/library/actions/mobile/platform/bridge.mjs +229 -13
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +27 -6
- package/library/actions/mobile/wallet/select_account.mjs +21 -17
- package/library/manifests/mobile.action-manifest.json +130 -1
- package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
- package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +147 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +137 -0
- package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +34 -1
- package/site/architecture.html +12 -2
- package/site/assets/style.css +20 -1
- package/site/cheatsheet.html +7 -6
- package/site/index.html +119 -77
- package/site/perps.html +195 -0
- package/site/recipes.html +23 -1
- package/site/reviewers.html +2 -1
- package/site/tutorials/index.html +2 -1
- package/site/tutorials/v1.html +3 -2
- package/site/tutorials/v2.html +6 -5
- package/site/tutorials/v3.html +43 -2
- package/site/tutorials/v4.html +2 -1
- package/site/tutorials/v5.html +2 -1
- package/site/tutorials/v6.html +2 -1
- package/site/tutorials/v7.html +2 -1
package/dist/adapters.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
2
4
|
import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
|
|
3
5
|
import {
|
|
4
6
|
runLiveAdapterScript
|
|
5
7
|
} from "./live-adapter-contract.js";
|
|
6
8
|
import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
|
|
7
|
-
import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
9
|
+
import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
8
10
|
import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
|
|
11
|
+
import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
9
13
|
const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
10
14
|
"ui.swipe",
|
|
11
15
|
"ui.pan",
|
|
@@ -34,8 +38,10 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
34
38
|
"metamask.perps.close_orders",
|
|
35
39
|
"metamask.perps.ensure_orders",
|
|
36
40
|
"metamask.perps.assert_orders",
|
|
41
|
+
"metamask.perps.clear_performance_caches",
|
|
37
42
|
"metamask.perps.start_state",
|
|
38
|
-
"metamask.perps.teardown_state"
|
|
43
|
+
"metamask.perps.teardown_state",
|
|
44
|
+
"metamask.perps.capture_performance"
|
|
39
45
|
]);
|
|
40
46
|
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set(["metamask.perps.read_account"]);
|
|
41
47
|
const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
|
|
@@ -191,6 +197,8 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
191
197
|
"metamask.perps.assert_orders",
|
|
192
198
|
"metamask.perps.start_state",
|
|
193
199
|
"metamask.perps.teardown_state",
|
|
200
|
+
"metamask.perps.capture_performance",
|
|
201
|
+
...platform === "mobile" ? ["metamask.perps.clear_performance_caches"] : [],
|
|
194
202
|
// read_account is core-only: only the headless core adapter implements it.
|
|
195
203
|
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
196
204
|
];
|
|
@@ -238,43 +246,107 @@ function mobileProbeOutput(status, input, projectRoot) {
|
|
|
238
246
|
function mobileBridgePath(_projectRoot) {
|
|
239
247
|
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
|
|
240
248
|
}
|
|
249
|
+
function resolveMobileWaitTarget(payload) {
|
|
250
|
+
const testId = optionalScalarText(
|
|
251
|
+
payload.test_id ?? payload.testID,
|
|
252
|
+
"Mobile ui.wait_for.test_id"
|
|
253
|
+
);
|
|
254
|
+
const expectedText = optionalScalarText(payload.text, "ui.wait_for.text");
|
|
255
|
+
if (!testId && !expectedText) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"Mobile ui.wait_for requires one of: test_id, testID, text."
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const expected = scalarText(
|
|
261
|
+
payload.expected,
|
|
262
|
+
"ui.wait_for.expected",
|
|
263
|
+
"present"
|
|
264
|
+
).toLowerCase();
|
|
265
|
+
const textMatch = scalarText(
|
|
266
|
+
payload.text_match ?? payload.textMatch,
|
|
267
|
+
"ui.wait_for.text_match",
|
|
268
|
+
"contains"
|
|
269
|
+
).toLowerCase();
|
|
270
|
+
if (!["contains", "exact"].includes(textMatch)) {
|
|
271
|
+
throw new Error("ui.wait_for.text_match must be contains or exact.");
|
|
272
|
+
}
|
|
273
|
+
if (!testId && textMatch === "exact") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Mobile text-only ui.wait_for supports text_match=contains; exact matching also requires test_id/testID."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
testId,
|
|
280
|
+
expectedText,
|
|
281
|
+
expected,
|
|
282
|
+
textMatch,
|
|
283
|
+
visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
|
|
284
|
+
};
|
|
285
|
+
}
|
|
241
286
|
async function waitForMobileTarget(input, payload) {
|
|
242
287
|
const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
|
|
243
288
|
const deadline = Date.now() + timeoutMs;
|
|
244
|
-
const testId
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
const { testId, expectedText, expected, textMatch, visibility } = resolveMobileWaitTarget(payload);
|
|
290
|
+
const query = {
|
|
291
|
+
...testId ? { testId } : {},
|
|
292
|
+
...expectedText && textMatch === "contains" ? { textContains: expectedText } : {},
|
|
293
|
+
visibility
|
|
294
|
+
};
|
|
295
|
+
const queryExpression = `(function(){
|
|
249
296
|
const api = globalThis.__AGENTIC__;
|
|
250
|
-
if (!api
|
|
251
|
-
|
|
297
|
+
if (!api || typeof api.queryUiTarget !== 'function') {
|
|
298
|
+
return Promise.resolve(JSON.stringify({ unsupported: true }));
|
|
299
|
+
}
|
|
300
|
+
return api.queryUiTarget(${JSON.stringify(query)}).then(function(result){
|
|
301
|
+
return JSON.stringify(result);
|
|
302
|
+
});
|
|
252
303
|
})()`;
|
|
253
|
-
const
|
|
254
|
-
|
|
304
|
+
const textExpression = testId ? `(function(){
|
|
305
|
+
const api = globalThis.__AGENTIC__;
|
|
306
|
+
if (!api?.getTextByTestId) return null;
|
|
307
|
+
return api.getTextByTestId(${JSON.stringify(testId)});
|
|
308
|
+
})()` : null;
|
|
309
|
+
let lastResult = null;
|
|
255
310
|
let lastText = null;
|
|
256
311
|
const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
|
|
257
312
|
while (Date.now() <= deadline) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
313
|
+
const result = await evalAsync(input, queryExpression);
|
|
314
|
+
if (!isRecord(result) || result.unsupported === true) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
|
|
317
|
+
);
|
|
262
318
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
319
|
+
lastResult = result;
|
|
320
|
+
const present = result.present === true;
|
|
321
|
+
const visible = result.visible === true;
|
|
322
|
+
let textMatched = !expectedText || textMatch === "contains";
|
|
323
|
+
if (expectedText && textMatch === "exact" && textExpression) {
|
|
267
324
|
lastText = await evalSync(input, textExpression);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
325
|
+
textMatched = traceText(lastText) === expectedText;
|
|
326
|
+
}
|
|
327
|
+
const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
|
|
328
|
+
if (matched) {
|
|
329
|
+
return {
|
|
330
|
+
matched: true,
|
|
331
|
+
...testId ? { testId } : {},
|
|
332
|
+
...expectedText ? { text: expectedText, textMatch } : {},
|
|
333
|
+
expected,
|
|
334
|
+
present,
|
|
335
|
+
visible,
|
|
336
|
+
visibility,
|
|
337
|
+
...isRecord(result.rect) ? { rect: result.rect } : {}
|
|
338
|
+
};
|
|
273
339
|
}
|
|
274
340
|
await sleep(250);
|
|
275
341
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
342
|
+
const target = [
|
|
343
|
+
testId ? `testID ${JSON.stringify(testId)}` : "",
|
|
344
|
+
expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
|
|
345
|
+
].filter(Boolean).join(" with ");
|
|
346
|
+
const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
|
|
349
|
+
);
|
|
278
350
|
}
|
|
279
351
|
const MOBILE_BRIDGE_HANDLERS = {
|
|
280
352
|
screenshot: handleMobileScreenshot,
|
|
@@ -354,13 +426,73 @@ async function handleMobileScroll(payload, context) {
|
|
|
354
426
|
const input = mobileUiInput(context, "scroll", payload);
|
|
355
427
|
const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
|
|
356
428
|
const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
|
|
357
|
-
const
|
|
429
|
+
const intoView = payload.scroll_into_view === true || payload.into_view === true;
|
|
430
|
+
if (testId && intoView) {
|
|
431
|
+
const before = await queryMobileViewportTarget(input, testId);
|
|
432
|
+
if (before.visible === true) {
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
testId,
|
|
436
|
+
intoView: true,
|
|
437
|
+
alreadyVisible: true,
|
|
438
|
+
rect: before.rect
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const args = testId ? [
|
|
443
|
+
"scroll-view",
|
|
444
|
+
"--test-id",
|
|
445
|
+
testId,
|
|
446
|
+
"--offset",
|
|
447
|
+
offset,
|
|
448
|
+
...intoView ? ["--into-view"] : [],
|
|
449
|
+
animatedFlag(payload)
|
|
450
|
+
] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
|
|
358
451
|
const result = await bridgeCommand(input, args);
|
|
452
|
+
if (testId && intoView) {
|
|
453
|
+
const after = await waitForMobileViewportTarget(
|
|
454
|
+
input,
|
|
455
|
+
testId,
|
|
456
|
+
Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3)
|
|
457
|
+
);
|
|
458
|
+
if (after.visible !== true) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`ui.scroll did not bring mobile testID ${JSON.stringify(testId)} into the viewport; last result=${JSON.stringify(after)}.`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
359
464
|
return {
|
|
360
465
|
...isRecord(result) ? result : { result },
|
|
361
|
-
intoView
|
|
466
|
+
intoView
|
|
362
467
|
};
|
|
363
468
|
}
|
|
469
|
+
async function queryMobileViewportTarget(input, testId) {
|
|
470
|
+
const expression = `(function(){
|
|
471
|
+
const api = globalThis.__AGENTIC__;
|
|
472
|
+
if (!api || typeof api.queryUiTarget !== 'function') {
|
|
473
|
+
return Promise.resolve(JSON.stringify({ unsupported: true }));
|
|
474
|
+
}
|
|
475
|
+
return api.queryUiTarget(${JSON.stringify({ testId, visibility: "viewport" })}).then(function(result){
|
|
476
|
+
return JSON.stringify(result);
|
|
477
|
+
});
|
|
478
|
+
})()`;
|
|
479
|
+
const result = await evalAsync(input, expression);
|
|
480
|
+
if (!isRecord(result) || result.unsupported === true) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"Mobile ui.scroll scroll_into_view requires the current __AGENTIC__.queryUiTarget bridge."
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
async function waitForMobileViewportTarget(input, testId, timeoutMs) {
|
|
488
|
+
const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 5e3));
|
|
489
|
+
let result = await queryMobileViewportTarget(input, testId);
|
|
490
|
+
while (result.visible !== true && Date.now() < deadline) {
|
|
491
|
+
await sleep(100);
|
|
492
|
+
result = await queryMobileViewportTarget(input, testId);
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
364
496
|
async function handleMobileWaitFor(payload, context) {
|
|
365
497
|
return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
|
|
366
498
|
}
|
|
@@ -543,13 +675,10 @@ async function executeNativeProviderAction(action, node, context, createTranspor
|
|
|
543
675
|
`${action} requires one selected ${platform} device. Next: mm-harness status --adapter mobile --all-devices --json`
|
|
544
676
|
);
|
|
545
677
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const variable = platform === "android" ? "ANDROID_PACKAGE_ID" : "IOS_BUNDLE_ID";
|
|
549
|
-
throw new Error(
|
|
550
|
-
`${action} requires the selected MetaMask app id. Next: export ${variable}=<installed-app-id>`
|
|
551
|
-
);
|
|
678
|
+
if (platform === "android" && action === "ui.swipe" && isCoordinateTarget(node.target)) {
|
|
679
|
+
return executeAndroidCoordinateSwipe(node, String(device));
|
|
552
680
|
}
|
|
681
|
+
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";
|
|
553
682
|
const session = `mm-harness-${process.pid}-${context.nodeId}`.replace(/[^a-zA-Z0-9._-]/gu, "-");
|
|
554
683
|
let transport;
|
|
555
684
|
try {
|
|
@@ -585,6 +714,66 @@ async function executeNativeProviderAction(action, node, context, createTranspor
|
|
|
585
714
|
if (closeError) throw closeError;
|
|
586
715
|
return output;
|
|
587
716
|
}
|
|
717
|
+
function isCoordinateTarget(value) {
|
|
718
|
+
return isRecord(value) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y);
|
|
719
|
+
}
|
|
720
|
+
async function executeAndroidCoordinateSwipe(node, device) {
|
|
721
|
+
const target = node.target;
|
|
722
|
+
const direction = String(node.direction ?? "").toLowerCase();
|
|
723
|
+
if (!["up", "down", "left", "right"].includes(direction)) {
|
|
724
|
+
throw new Error("ui.swipe.direction must be up, down, left, or right.");
|
|
725
|
+
}
|
|
726
|
+
const distance = Number(node.distance ?? 300);
|
|
727
|
+
const durationMs = Number(node.duration_ms ?? 300);
|
|
728
|
+
if (!Number.isFinite(distance) || distance <= 0) {
|
|
729
|
+
throw new Error("ui.swipe.distance must be a positive number.");
|
|
730
|
+
}
|
|
731
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
732
|
+
throw new Error("ui.swipe.duration_ms must be a positive number.");
|
|
733
|
+
}
|
|
734
|
+
const start = { x: Math.round(target.x), y: Math.round(target.y) };
|
|
735
|
+
const end = {
|
|
736
|
+
x: Math.round(start.x + (direction === "left" ? -distance : direction === "right" ? distance : 0)),
|
|
737
|
+
y: Math.round(start.y + (direction === "up" ? -distance : direction === "down" ? distance : 0))
|
|
738
|
+
};
|
|
739
|
+
const adbPath = resolveMobileToolPath("adb", { required: true });
|
|
740
|
+
try {
|
|
741
|
+
await execFileAsync(
|
|
742
|
+
adbPath,
|
|
743
|
+
[
|
|
744
|
+
"-s",
|
|
745
|
+
device,
|
|
746
|
+
"shell",
|
|
747
|
+
"input",
|
|
748
|
+
"swipe",
|
|
749
|
+
String(start.x),
|
|
750
|
+
String(start.y),
|
|
751
|
+
String(end.x),
|
|
752
|
+
String(end.y),
|
|
753
|
+
String(Math.round(durationMs))
|
|
754
|
+
],
|
|
755
|
+
{ encoding: "utf8", timeout: Math.round(durationMs) + 1e4 }
|
|
756
|
+
);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Android ui.swipe failed on ${device}: ${error instanceof Error ? error.message : String(error)}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const settleMs = node.settle === false ? 0 : 300;
|
|
763
|
+
if (settleMs > 0) await sleep(settleMs);
|
|
764
|
+
return {
|
|
765
|
+
action: "ui.swipe",
|
|
766
|
+
resolvedStart: start,
|
|
767
|
+
resolvedEnd: end,
|
|
768
|
+
backend: "adb-input-swipe",
|
|
769
|
+
segments: 1,
|
|
770
|
+
settlement: {
|
|
771
|
+
method: "bounded-post-gesture",
|
|
772
|
+
waitedMs: settleMs,
|
|
773
|
+
lifecycleNeutral: true
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
588
777
|
function mobileNativePlatform(env) {
|
|
589
778
|
if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
|
|
590
779
|
if (env.ADB_SERIAL || env.ANDROID_SERIAL) return "android";
|
|
@@ -662,5 +851,6 @@ export {
|
|
|
662
851
|
createMetaMaskUiTransport,
|
|
663
852
|
hideMobileHudOnTeardown,
|
|
664
853
|
isMobileHudLifecycleSkip,
|
|
665
|
-
normalizeUiWaitNode
|
|
854
|
+
normalizeUiWaitNode,
|
|
855
|
+
resolveMobileWaitTarget
|
|
666
856
|
};
|
package/dist/command-contract.js
CHANGED
|
@@ -228,6 +228,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
228
228
|
launch: {
|
|
229
229
|
options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, {
|
|
230
230
|
"--build": bool(),
|
|
231
|
+
"--clear-metro": bool(),
|
|
231
232
|
"--verify": bool(),
|
|
232
233
|
"--runway": bool(),
|
|
233
234
|
"--watch": bool(),
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { resolveMobileToolPath } from "../../library/actions/mobile/platform/tool-paths.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findAvailableIosSimulatorById,
|
|
5
|
+
listConnectedDevices
|
|
6
|
+
} from "../devices.js";
|
|
7
|
+
import { persistExplicitDevicePin } from "../runtime-context.js";
|
|
4
8
|
import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
|
|
9
|
+
const DEVICE_SELECT_HINT = "Pass --device <name|udid|serial> to select one target. Do not shut down other connected devices to force selection.";
|
|
5
10
|
function normalizeDeviceName(value) {
|
|
6
11
|
return value.replace(/_/gu, " ").trim();
|
|
7
12
|
}
|
|
@@ -20,6 +25,7 @@ function resolveAndroidModel(serial) {
|
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
function setAndroidDeviceEnv(id, fallbackName) {
|
|
28
|
+
process.env.PLATFORM = "android";
|
|
23
29
|
process.env.ADB_SERIAL = id;
|
|
24
30
|
process.env.ANDROID_SERIAL = id;
|
|
25
31
|
process.env.ANDROID_DEVICE = id;
|
|
@@ -29,7 +35,8 @@ function setAndroidDeviceEnv(id, fallbackName) {
|
|
|
29
35
|
process.env.IOS_SIMULATOR = "";
|
|
30
36
|
}
|
|
31
37
|
function setIosDeviceEnv(id, fallbackName) {
|
|
32
|
-
process.env.
|
|
38
|
+
process.env.PLATFORM = "ios";
|
|
39
|
+
process.env.IOS_SIMULATOR = fallbackName?.trim() || id;
|
|
33
40
|
process.env.SIM_UDID = id;
|
|
34
41
|
process.env.ADB_SERIAL = "";
|
|
35
42
|
process.env.ANDROID_SERIAL = "";
|
|
@@ -113,13 +120,14 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
113
120
|
}
|
|
114
121
|
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
115
122
|
else setIosDeviceEnv(matched.id, matched.name);
|
|
123
|
+
persistExplicitDevicePin(targetPath(options), matched);
|
|
116
124
|
return { ok: true };
|
|
117
125
|
};
|
|
118
126
|
const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
|
|
119
127
|
if (androidById) {
|
|
120
128
|
return setResolvedDevice(androidById);
|
|
121
129
|
}
|
|
122
|
-
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
|
|
130
|
+
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device) ?? (command === "launch" && platformPreference === "ios" ? findAvailableIosSimulatorById(device) : void 0);
|
|
123
131
|
if (iosById) {
|
|
124
132
|
return setResolvedDevice(iosById);
|
|
125
133
|
}
|
|
@@ -221,7 +229,8 @@ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (a
|
|
|
221
229
|
Connected devices:
|
|
222
230
|
${formatConnectedDevices(connected)}
|
|
223
231
|
Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
|
|
224
|
-
--device ${d.id}`).join("")}
|
|
232
|
+
--device ${d.name ?? d.id}`).join("")}
|
|
233
|
+
${DEVICE_SELECT_HINT}`,
|
|
225
234
|
userAction: deviceRecovery(options, opts.rerun)
|
|
226
235
|
};
|
|
227
236
|
}
|
|
@@ -240,8 +249,10 @@ ${formatConnectedDevices(connected)}
|
|
|
240
249
|
message: `${targetable.length} mobile devices available \u2014 ${command} needs exactly one target.
|
|
241
250
|
Connected devices:
|
|
242
251
|
${formatConnectedDevices(connected)}
|
|
243
|
-
` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id
|
|
244
|
-
|
|
252
|
+
` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.
|
|
253
|
+
${DEVICE_SELECT_HINT}` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
|
|
254
|
+
--device ${d.name ?? d.id}`).join("")}
|
|
255
|
+
${DEVICE_SELECT_HINT}`),
|
|
245
256
|
userAction: deviceRecovery(options, opts.rerun)
|
|
246
257
|
};
|
|
247
258
|
}
|
|
@@ -44,6 +44,7 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
|
44
44
|
"sidepanel",
|
|
45
45
|
"fullscreen",
|
|
46
46
|
"runway",
|
|
47
|
+
"clearMetro",
|
|
47
48
|
"json",
|
|
48
49
|
"jsonStream"
|
|
49
50
|
]);
|
|
@@ -122,9 +123,13 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
122
123
|
const wantVerify = flag(options, "verify");
|
|
123
124
|
const wantWatch = flag(options, "watch");
|
|
124
125
|
const wantRunway = flag(options, "runway");
|
|
126
|
+
const wantClearMetro = flag(options, "clearMetro");
|
|
125
127
|
if (wantRunway && adapter !== "mobile") {
|
|
126
128
|
return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
|
|
127
129
|
}
|
|
130
|
+
if (wantClearMetro && adapter !== "mobile") {
|
|
131
|
+
return launchUsage(jsonOutput, stream, "--clear-metro is mobile-only.", "drop --clear-metro for the extension");
|
|
132
|
+
}
|
|
128
133
|
const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
|
|
129
134
|
for (const portFlag of ["cdpPort", "watcherPort"]) {
|
|
130
135
|
const value = str(options, portFlag);
|
|
@@ -160,7 +165,7 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
160
165
|
const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
|
|
161
166
|
writeInteractiveProgress(
|
|
162
167
|
machine,
|
|
163
|
-
`\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
|
|
168
|
+
`\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}${wantClearMetro ? " \xB7 clear cache" : ""}`
|
|
164
169
|
);
|
|
165
170
|
}
|
|
166
171
|
const state = newHealState();
|
|
@@ -221,7 +226,17 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
stream.phase("launch");
|
|
224
|
-
let attempt = await executeComposition(
|
|
229
|
+
let attempt = await executeComposition(
|
|
230
|
+
adapter,
|
|
231
|
+
mobileTarget,
|
|
232
|
+
tier,
|
|
233
|
+
wantWatch,
|
|
234
|
+
target,
|
|
235
|
+
machine,
|
|
236
|
+
displayMode,
|
|
237
|
+
false,
|
|
238
|
+
wantClearMetro
|
|
239
|
+
);
|
|
225
240
|
if (attempt.status === 0) {
|
|
226
241
|
return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
227
242
|
}
|
|
@@ -379,7 +394,7 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
|
|
|
379
394
|
if (adapter === "mobile" && (device || mobileTarget === "ios" || mobileTarget === "android")) {
|
|
380
395
|
const targetingOptions = {
|
|
381
396
|
...options,
|
|
382
|
-
...mobileTarget
|
|
397
|
+
...mobileTarget ? { platform: mobileTarget } : {}
|
|
383
398
|
};
|
|
384
399
|
const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
|
|
385
400
|
if ("code" in result) {
|
|
@@ -447,9 +462,16 @@ function nativeInputsChanged(target, adapter) {
|
|
|
447
462
|
return false;
|
|
448
463
|
}
|
|
449
464
|
}
|
|
450
|
-
async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false) {
|
|
465
|
+
async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false, clearMobileMetro = false) {
|
|
451
466
|
if (adapter === "mobile") {
|
|
452
|
-
return launchMobile(
|
|
467
|
+
return launchMobile(
|
|
468
|
+
target,
|
|
469
|
+
mobileTarget,
|
|
470
|
+
tier,
|
|
471
|
+
json,
|
|
472
|
+
restartMobileApp,
|
|
473
|
+
clearMobileMetro
|
|
474
|
+
);
|
|
453
475
|
}
|
|
454
476
|
return launchExtension(target, tier, wantWatch, displayMode);
|
|
455
477
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { prepareMobile } from "../../adapters/mobile/prepare.js";
|
|
2
2
|
import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
|
|
3
|
-
async function launchMobile(target, mobileTarget, tier, json, restartApp = false) {
|
|
3
|
+
async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
|
|
4
4
|
await ensureHarnessFresh(target, "mobile");
|
|
5
5
|
const platform = mobileTarget ?? "ios";
|
|
6
6
|
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
|
|
7
7
|
const preflightMode = tier === "build" ? "auto" : "fast";
|
|
8
|
-
return prepareMobile(target, {
|
|
8
|
+
return prepareMobile(target, {
|
|
9
|
+
platform,
|
|
10
|
+
json,
|
|
11
|
+
watcherPort,
|
|
12
|
+
preflightMode,
|
|
13
|
+
restartApp,
|
|
14
|
+
clearMetro
|
|
15
|
+
});
|
|
9
16
|
}
|
|
10
17
|
export {
|
|
11
18
|
launchMobile
|
|
@@ -135,10 +135,12 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
|
|
|
135
135
|
process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR
|
|
136
136
|
);
|
|
137
137
|
const { createMetaMaskRunner } = await import("../runner.js");
|
|
138
|
+
const recipeDocument = runtimeRecipeDocument(recipe, absoluteRecipePath);
|
|
139
|
+
const suppressHudForLifecycleTiming = adapter === "mobile" && mobileRecipeRequiresUninterruptedLifecycleTiming(recipeDocument);
|
|
138
140
|
const runner = await createMetaMaskRunner(adapter, manifest, {
|
|
139
141
|
quietStdout: runtimeOptions.stdoutIsMachineContract === true,
|
|
140
142
|
suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
|
|
141
|
-
autoHud: trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
|
|
143
|
+
autoHud: suppressHudForLifecycleTiming ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
|
|
142
144
|
onActionEvent: runtimeOptions.onActionEvent,
|
|
143
145
|
actionSources,
|
|
144
146
|
// A direct engineer invocation is the explicit trust boundary for a
|
|
@@ -169,6 +171,25 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
|
|
|
169
171
|
}
|
|
170
172
|
};
|
|
171
173
|
}
|
|
174
|
+
function runtimeRecipeDocument(recipe, absoluteRecipePath) {
|
|
175
|
+
if (typeof recipe !== "string") return recipe;
|
|
176
|
+
if (!absoluteRecipePath || !fs.existsSync(absoluteRecipePath)) return void 0;
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(fs.readFileSync(absoluteRecipePath, "utf8"));
|
|
179
|
+
} catch {
|
|
180
|
+
return void 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function mobileRecipeRequiresUninterruptedLifecycleTiming(recipe) {
|
|
184
|
+
if (!isRecord(recipe)) return false;
|
|
185
|
+
const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
|
|
186
|
+
const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
|
|
187
|
+
if (!nodes) return false;
|
|
188
|
+
const actions = new Set(
|
|
189
|
+
Object.values(nodes).filter(isRecord).map((node) => node.action).filter((action) => typeof action === "string")
|
|
190
|
+
);
|
|
191
|
+
return actions.has("app.lifecycle") && actions.has("metamask.perps.capture_performance");
|
|
192
|
+
}
|
|
172
193
|
function recipeProcessEnvironment() {
|
|
173
194
|
const env = { ...process.env };
|
|
174
195
|
delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
@@ -1126,11 +1147,20 @@ async function recoverRunInfra(adapter, target, json) {
|
|
|
1126
1147
|
}
|
|
1127
1148
|
if (adapter === "mobile") {
|
|
1128
1149
|
const { launchMobile } = await import("./launch/mobile.js");
|
|
1129
|
-
const platform =
|
|
1150
|
+
const platform = resolveMobileRecoveryPlatform();
|
|
1130
1151
|
return launchMobile(target, platform, "quick", json);
|
|
1131
1152
|
}
|
|
1132
1153
|
return { status: 0, output: "headless run retry" };
|
|
1133
1154
|
}
|
|
1155
|
+
function resolveMobileRecoveryPlatform(env = process.env) {
|
|
1156
|
+
if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
|
|
1157
|
+
const androidPinned = Boolean(
|
|
1158
|
+
env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE
|
|
1159
|
+
);
|
|
1160
|
+
const iosPinned = Boolean(env.IOS_SIMULATOR || env.SIM_UDID);
|
|
1161
|
+
if (androidPinned && !iosPinned) return "android";
|
|
1162
|
+
return "ios";
|
|
1163
|
+
}
|
|
1134
1164
|
function readRunFailureText(result) {
|
|
1135
1165
|
try {
|
|
1136
1166
|
const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
|
|
@@ -1218,10 +1248,12 @@ export {
|
|
|
1218
1248
|
ensureMobileProofRuntime,
|
|
1219
1249
|
executeWithHealBounds,
|
|
1220
1250
|
listRunnableRecipes,
|
|
1251
|
+
mobileRecipeRequiresUninterruptedLifecycleTiming,
|
|
1221
1252
|
preflightRecipe,
|
|
1222
1253
|
prepareHeal,
|
|
1223
1254
|
recoverRunInfra,
|
|
1224
1255
|
resolveMetaMaskLibrarySources,
|
|
1256
|
+
resolveMobileRecoveryPlatform,
|
|
1225
1257
|
resolveRunRecipeArg,
|
|
1226
1258
|
runOneNode,
|
|
1227
1259
|
runRecipe,
|
package/dist/devices.js
CHANGED
|
@@ -6,6 +6,12 @@ function listConnectedDevices(platform) {
|
|
|
6
6
|
if (!platform || platform === "ios") devices.push(...listIosSimulators());
|
|
7
7
|
return devices;
|
|
8
8
|
}
|
|
9
|
+
function findAvailableIosSimulatorById(id) {
|
|
10
|
+
for (const simulator of listIosSimulators("available")) {
|
|
11
|
+
if (simulator.id === id) return simulator;
|
|
12
|
+
}
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
9
15
|
function listAndroidDevices() {
|
|
10
16
|
const adbPath = resolveMobileToolPath("adb");
|
|
11
17
|
if (!adbPath) return [];
|
|
@@ -33,10 +39,10 @@ function listAndroidDevices() {
|
|
|
33
39
|
}
|
|
34
40
|
return devices;
|
|
35
41
|
}
|
|
36
|
-
function listIosSimulators() {
|
|
42
|
+
function listIosSimulators(scope = "booted") {
|
|
37
43
|
let output;
|
|
38
44
|
try {
|
|
39
|
-
output = execFileSync("xcrun", ["simctl", "list", "devices",
|
|
45
|
+
output = execFileSync("xcrun", ["simctl", "list", "devices", scope, "-j"], {
|
|
40
46
|
encoding: "utf8",
|
|
41
47
|
timeout: 5e3,
|
|
42
48
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -65,5 +71,6 @@ function listIosSimulators() {
|
|
|
65
71
|
return devices;
|
|
66
72
|
}
|
|
67
73
|
export {
|
|
74
|
+
findAvailableIosSimulatorById,
|
|
68
75
|
listConnectedDevices
|
|
69
76
|
};
|
package/dist/heal-bounds.js
CHANGED
|
@@ -61,6 +61,7 @@ async function ensureOverlay(adapter, target, heal, state, json) {
|
|
|
61
61
|
return { ok: true };
|
|
62
62
|
}
|
|
63
63
|
function classifyFailure(output) {
|
|
64
|
+
if (/SCREENSHOT_PROTECTED|screenshot is protected by FLAG_SECURE/iu.test(output)) return "screenshot-protected";
|
|
64
65
|
if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return "wallet";
|
|
65
66
|
if (/metro|cdp|chrome|bridge|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
|
|
66
67
|
return "infra";
|
|
@@ -86,6 +87,15 @@ function checkHealBounds(target, output, state) {
|
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
const failureClass = classifyFailure(output);
|
|
90
|
+
if (failureClass === "screenshot-protected") {
|
|
91
|
+
return {
|
|
92
|
+
code: "SCREENSHOT_PROTECTED",
|
|
93
|
+
exitCode: EXIT.runtime,
|
|
94
|
+
message: "the focused Android window intentionally blocks screenshots; a black capture cannot be used as evidence.",
|
|
95
|
+
userAction: "unlock or navigate to a non-protected screen, then rerun the screenshot action",
|
|
96
|
+
originalError
|
|
97
|
+
};
|
|
98
|
+
}
|
|
89
99
|
if (failureClass === "wallet") {
|
|
90
100
|
return {
|
|
91
101
|
code: "WALLET_STATE_REQUIRED",
|