@deeeed/metamask-harness 0.34.0 → 0.34.2

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/adapters/manifest.json +0 -8
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +236 -3
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
  5. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +22 -15
  6. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
  7. package/adapters/mobile/launch-metro.cjs +6 -4
  8. package/adapters/mobile/open-device.sh +170 -19
  9. package/adapters/mobile/start-metro.sh +165 -93
  10. package/adapters/mobile/stop-metro.sh +1 -0
  11. package/dist/adapters/mobile/prepare.js +1 -4
  12. package/dist/adapters/mobile/video-recorder.js +12 -10
  13. package/dist/adapters.js +134 -6
  14. package/dist/commands/launch/index.js +9 -0
  15. package/dist/commands/launch/mobile.js +17 -3
  16. package/dist/live-adapter-contract.js +10 -3
  17. package/dist/recipe-security.js +2 -0
  18. package/dist/runner.js +53 -8
  19. package/dist/runtime-context.js +1 -0
  20. package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
  21. package/library/actions/mobile/perps/performance-capture.mjs +579 -111
  22. package/library/actions/mobile/perps/perps.mjs +221 -38
  23. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
  24. package/library/actions/mobile/platform/bridge.mjs +102 -8
  25. package/library/actions/mobile/wallet/ensure_unlocked.mjs +35 -8
  26. package/library/manifests/mobile.action-manifest.json +243 -511
  27. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +20 -2
  28. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +2 -0
  29. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
  30. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
  31. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
  32. package/package.json +1 -1
  33. package/adapters/mobile/metro-config.cjs +0 -93
@@ -7,7 +7,16 @@ import {
7
7
  } from "../../recording-target.js";
8
8
  const RECORDING_START_TIMEOUT_MS = 5e3;
9
9
  const RECORDING_STOP_TIMEOUT_MS = 1e4;
10
- const RECORDING_TIME_LIMIT_SECONDS = 180;
10
+ const RECORDING_TIME_LIMIT_SECONDS = 0;
11
+ function androidScreenrecordArgs(remotePath) {
12
+ return [
13
+ "shell",
14
+ "screenrecord",
15
+ "--time-limit",
16
+ String(RECORDING_TIME_LIMIT_SECONDS),
17
+ remotePath
18
+ ];
19
+ }
11
20
  function createAndroidVideoRecorder(serial) {
12
21
  return {
13
22
  name: "adb-screenrecord",
@@ -139,15 +148,7 @@ async function startAndroidRecording(serial, request) {
139
148
  const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
140
149
  const child = spawn(
141
150
  "adb",
142
- [
143
- "-s",
144
- serial,
145
- "shell",
146
- "screenrecord",
147
- "--time-limit",
148
- String(RECORDING_TIME_LIMIT_SECONDS),
149
- remotePath
150
- ],
151
+ ["-s", serial, ...androidScreenrecordArgs(remotePath)],
151
152
  { stdio: ["ignore", "pipe", "pipe"] }
152
153
  );
153
154
  const stderr = [];
@@ -277,6 +278,7 @@ function sleep(durationMs) {
277
278
  return new Promise((resolve) => setTimeout(resolve, durationMs));
278
279
  }
279
280
  export {
281
+ androidScreenrecordArgs,
280
282
  createAndroidVideoRecorder,
281
283
  createIosSimulatorVideoRecorder
282
284
  };
package/dist/adapters.js CHANGED
@@ -39,6 +39,8 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
39
39
  "metamask.perps.ensure_orders",
40
40
  "metamask.perps.assert_orders",
41
41
  "metamask.perps.clear_performance_caches",
42
+ "metamask.perps.measure_homepage_visible",
43
+ "metamask.perps.prepare_local_snapshot_endpoint",
42
44
  "metamask.perps.start_state",
43
45
  "metamask.perps.teardown_state",
44
46
  "metamask.perps.capture_performance"
@@ -198,7 +200,11 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
198
200
  "metamask.perps.start_state",
199
201
  "metamask.perps.teardown_state",
200
202
  "metamask.perps.capture_performance",
201
- ...platform === "mobile" ? ["metamask.perps.clear_performance_caches"] : [],
203
+ ...platform === "mobile" ? [
204
+ "metamask.perps.clear_performance_caches",
205
+ "metamask.perps.measure_homepage_visible",
206
+ "metamask.perps.prepare_local_snapshot_endpoint"
207
+ ] : [],
202
208
  // read_account is core-only: only the headless core adapter implements it.
203
209
  ...platform === "core" ? ["metamask.perps.read_account"] : []
204
210
  ];
@@ -283,6 +289,17 @@ function resolveMobileWaitTarget(payload) {
283
289
  visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
284
290
  };
285
291
  }
292
+ const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
293
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
294
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
295
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
296
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
297
+ ]);
298
+ function isRetryableMobileWaitError(error) {
299
+ return Boolean(
300
+ isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
301
+ );
302
+ }
286
303
  async function waitForMobileTarget(input, payload) {
287
304
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
288
305
  const deadline = Date.now() + timeoutMs;
@@ -308,9 +325,28 @@ async function waitForMobileTarget(input, payload) {
308
325
  })()` : null;
309
326
  let lastResult = null;
310
327
  let lastText = null;
328
+ let lastBridgeError = null;
311
329
  const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
312
330
  while (Date.now() <= deadline) {
313
- const result = await evalAsync(input, queryExpression);
331
+ const remainingMs = Math.max(1, deadline - Date.now());
332
+ const probeTimeoutMs = Math.min(5e3, remainingMs);
333
+ const probeInput = {
334
+ ...input,
335
+ node: {
336
+ ...input.node,
337
+ bridge_timeout_ms: probeTimeoutMs,
338
+ cdp_timeout_ms: probeTimeoutMs
339
+ }
340
+ };
341
+ let result;
342
+ try {
343
+ result = await evalAsync(probeInput, queryExpression);
344
+ } catch (error) {
345
+ if (!isRetryableMobileWaitError(error)) throw error;
346
+ lastBridgeError = error;
347
+ await sleep(250);
348
+ continue;
349
+ }
314
350
  if (!isRecord(result) || result.unsupported === true) {
315
351
  throw new Error(
316
352
  "Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
@@ -321,7 +357,14 @@ async function waitForMobileTarget(input, payload) {
321
357
  const visible = result.visible === true;
322
358
  let textMatched = !expectedText || textMatch === "contains";
323
359
  if (expectedText && textMatch === "exact" && textExpression) {
324
- lastText = await evalSync(input, textExpression);
360
+ try {
361
+ lastText = await evalSync(probeInput, textExpression);
362
+ } catch (error) {
363
+ if (!isRetryableMobileWaitError(error)) throw error;
364
+ lastBridgeError = error;
365
+ await sleep(250);
366
+ continue;
367
+ }
325
368
  textMatched = traceText(lastText) === expectedText;
326
369
  }
327
370
  const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
@@ -344,8 +387,9 @@ async function waitForMobileTarget(input, payload) {
344
387
  expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
345
388
  ].filter(Boolean).join(" with ");
346
389
  const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
390
+ const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
347
391
  throw new Error(
348
- `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
392
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
349
393
  );
350
394
  }
351
395
  const MOBILE_BRIDGE_HANDLERS = {
@@ -427,6 +471,48 @@ async function handleMobileScroll(payload, context) {
427
471
  const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
428
472
  const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
429
473
  const intoView = payload.scroll_into_view === true || payload.into_view === true;
474
+ const measureFromTestId = optionalScalarText(
475
+ payload.measure_from_test_id ?? payload.measureFromTestId,
476
+ "ui.scroll.measure_from_test_id"
477
+ );
478
+ const measureTargetTestId = optionalScalarText(
479
+ payload.measure_target_test_id ?? payload.measureTargetTestId,
480
+ "ui.scroll.measure_target_test_id"
481
+ );
482
+ const measureTargetText = optionalScalarText(
483
+ payload.measure_target_text ?? payload.measureTargetText,
484
+ "ui.scroll.measure_target_text"
485
+ );
486
+ const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
487
+ const measureRequiredPresentTestId = optionalScalarText(
488
+ payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
489
+ "ui.scroll.measure_required_present_test_id"
490
+ );
491
+ if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
492
+ throw new Error(
493
+ "ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
494
+ );
495
+ }
496
+ if (measureFromTestId && measureTargetCount === 1) {
497
+ if (intoView) {
498
+ throw new Error(
499
+ "ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
500
+ );
501
+ }
502
+ return measureMobileScrollTransition(input, {
503
+ animated: payload.animated === true,
504
+ fromTestId: measureFromTestId,
505
+ offset: Number(offset),
506
+ ...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
507
+ ...measureTargetText ? { targetText: measureTargetText } : {},
508
+ ...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
509
+ timeoutMs: Math.max(
510
+ 1,
511
+ Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
512
+ ),
513
+ ...testId ? { scrollTestId: testId } : {}
514
+ });
515
+ }
430
516
  if (testId && intoView) {
431
517
  const before = await queryMobileViewportTarget(input, testId);
432
518
  if (before.visible === true) {
@@ -448,12 +534,23 @@ async function handleMobileScroll(payload, context) {
448
534
  ...intoView ? ["--into-view"] : [],
449
535
  animatedFlag(payload)
450
536
  ] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
451
- const result = await bridgeCommand(input, args);
537
+ const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
538
+ const deadline = Date.now() + timeoutMs;
539
+ let result;
540
+ while (true) {
541
+ try {
542
+ result = await bridgeCommand(input, args);
543
+ break;
544
+ } catch (error) {
545
+ if (!testId || Date.now() >= deadline) throw error;
546
+ await sleep(100);
547
+ }
548
+ }
452
549
  if (testId && intoView) {
453
550
  const after = await waitForMobileViewportTarget(
454
551
  input,
455
552
  testId,
456
- Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3)
553
+ timeoutMs
457
554
  );
458
555
  if (after.visible !== true) {
459
556
  throw new Error(
@@ -466,6 +563,36 @@ async function handleMobileScroll(payload, context) {
466
563
  intoView
467
564
  };
468
565
  }
566
+ async function measureMobileScrollTransition(input, options) {
567
+ const result = await bridgeCommand(
568
+ {
569
+ ...input,
570
+ node: {
571
+ ...input.node,
572
+ bridge_timeout_ms: options.timeoutMs + 5e3,
573
+ cdp_timeout_ms: options.timeoutMs + 5e3
574
+ }
575
+ },
576
+ ["measure-scroll-transition", JSON.stringify(options)]
577
+ );
578
+ if (!isRecord(result) || result.unsupported === true) {
579
+ throw new Error(
580
+ "ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
581
+ );
582
+ }
583
+ if (result.ok !== true) {
584
+ throw new Error(
585
+ `ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
586
+ );
587
+ }
588
+ return {
589
+ ...result,
590
+ measureFromTestId: options.fromTestId,
591
+ ...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
592
+ ...options.targetText ? { measureTargetText: options.targetText } : {},
593
+ ...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
594
+ };
595
+ }
469
596
  async function queryMobileViewportTarget(input, testId) {
470
597
  const expression = `(function(){
471
598
  const api = globalThis.__AGENTIC__;
@@ -851,6 +978,7 @@ export {
851
978
  createMetaMaskUiTransport,
852
979
  hideMobileHudOnTeardown,
853
980
  isMobileHudLifecycleSkip,
981
+ isRetryableMobileWaitError,
854
982
  normalizeUiWaitNode,
855
983
  resolveMobileWaitTarget
856
984
  };
@@ -316,6 +316,15 @@ async function handleLaunchLocked(argv, stream) {
316
316
  originalError: attempt.output.trim() || void 0
317
317
  });
318
318
  }
319
+ if (adapter === "mobile" && tier === "build" && /open-device: (?:Android|iOS) native build failed/u.test(attempt.output)) {
320
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
321
+ code: "MOBILE_NATIVE_BUILD_FAILED",
322
+ message: "the selected Mobile native build failed; refusing to repeat the expensive build as a Metro recovery.",
323
+ recoverable: false,
324
+ exitCode: EXIT.runtime,
325
+ originalError: attempt.output.trim() || void 0
326
+ });
327
+ }
319
328
  const bound = checkHealBounds(target, attempt.output, state);
320
329
  if (bound !== null) {
321
330
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
@@ -1,18 +1,32 @@
1
1
  import { prepareMobile } from "../../adapters/mobile/prepare.js";
2
+ import {
3
+ mobileSourceCheck,
4
+ recordMobileSourceBaseline
5
+ } from "../../adapters/mobile/source-freshness.js";
2
6
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
+ import { EXIT } from "../shared.js";
3
8
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
4
9
  await ensureHarnessFresh(target, "mobile");
5
10
  const platform = mobileTarget ?? "ios";
6
11
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
7
- const preflightMode = tier === "build" ? "auto" : "fast";
8
- return prepareMobile(target, {
12
+ const preflightMode = tier === "build" ? "rebuild-native" : "fast";
13
+ const source = mobileSourceCheck(target);
14
+ const result = await prepareMobile(target, {
9
15
  platform,
10
16
  json,
11
17
  watcherPort,
12
18
  preflightMode,
13
- restartApp,
19
+ restartApp: restartApp || source.status !== "current",
14
20
  clearMetro
15
21
  });
22
+ if (result.status !== 0) return result;
23
+ if (!recordMobileSourceBaseline(target, source.fingerprint)) {
24
+ const message = "Mobile source changed while the app was launching; the loaded-source baseline was not recorded.\n Next: re-run mm-harness launch.";
25
+ if (!json) process.stderr.write(`${message}
26
+ `);
27
+ return { status: EXIT.runtime, output: message };
28
+ }
29
+ return result;
16
30
  }
17
31
  export {
18
32
  launchMobile
@@ -352,9 +352,16 @@ function liveAdapterProcessTimeoutMs(node) {
352
352
  }
353
353
  const settleMs = Number(node.settle_ms);
354
354
  const settleAllowance = Number.isFinite(settleMs) && settleMs > 0 ? settleMs : 0;
355
- if (node.timeout_ms != null) {
356
- const timeoutMs = Number(node.timeout_ms);
357
- return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + settleAllowance + 5e3 : 6e4;
355
+ const actionTimeouts = [
356
+ node.target_timeout_ms,
357
+ node.unlock_timeout_ms,
358
+ node.timeout_ms
359
+ ].map(Number).filter((value) => Number.isFinite(value) && value > 0);
360
+ if (actionTimeouts.length > 0) {
361
+ return Math.max(
362
+ 6e4,
363
+ actionTimeouts.reduce((total, value) => total + value, 0) + settleAllowance + 5e3
364
+ );
358
365
  }
359
366
  return 6e4 + settleAllowance;
360
367
  }
@@ -29,6 +29,8 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
29
29
  "metamask.perps.ensure_positions",
30
30
  "metamask.perps.ensure_orders",
31
31
  "metamask.perps.clear_performance_caches",
32
+ "metamask.perps.measure_homepage_visible",
33
+ "metamask.perps.prepare_local_snapshot_endpoint",
32
34
  "metamask.perps.start_state",
33
35
  "metamask.perps.teardown_state",
34
36
  // Consent changes wallet state and is therefore an app mutation.
package/dist/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
3
3
  import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
4
4
  import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
@@ -239,6 +239,8 @@ function isAutomaticHudProgress(action, node, nodeId) {
239
239
  function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe = probeMobileLifecycleRuntime, sourceFreshness = {
240
240
  fingerprint: mobileSourceFingerprint,
241
241
  record: recordMobileSourceBaseline
242
+ }, processIdentity = {
243
+ readIosPid: readIosAppPid
242
244
  }) {
243
245
  if (adapter !== "mobile") return lifecycle;
244
246
  return lifecycle.map((entry) => ({
@@ -246,6 +248,8 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
246
248
  async execute(node, context) {
247
249
  const command = node.command ?? node.event ?? node.state;
248
250
  const reloadsSource = command === "launch" || command === "foreground" || command === "restart";
251
+ const checksIosContinuity = command === "foreground" && !isAndroidLifecycle(node, context.env ?? {});
252
+ const processBefore = checksIosContinuity ? processIdentity.readIosPid(node, context) : void 0;
249
253
  const fingerprint = reloadsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
250
254
  const androidRestart = command === "restart" && isAndroidLifecycle(node, context.env ?? {});
251
255
  const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
@@ -261,6 +265,25 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
261
265
  if (reloadsSource) {
262
266
  await readinessProbe(node, context);
263
267
  }
268
+ if (checksIosContinuity) {
269
+ const processAfter = processIdentity.readIosPid(node, context);
270
+ const actualTransition = processBefore !== null && processBefore === processAfter ? "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
271
+ const resultRecord = asRecord(result);
272
+ result = {
273
+ ...resultRecord,
274
+ output: {
275
+ ...asRecord(resultRecord.output),
276
+ actualTransition,
277
+ processBefore,
278
+ processAfter
279
+ }
280
+ };
281
+ if (node.require_process_continuity === true && actualTransition !== "foreground_resume") {
282
+ throw new Error(
283
+ `iOS foreground did not preserve the app process (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample is a cold relaunch, not a reconnect.`
284
+ );
285
+ }
286
+ }
264
287
  if (fingerprint !== void 0 && !sourceFreshness.record(context.projectRoot, fingerprint)) {
265
288
  throw new Error(
266
289
  "Mobile source changed while the app was reloading; the loaded-source baseline was not recorded. Next: retry app.lifecycle restart."
@@ -270,6 +293,26 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
270
293
  }
271
294
  }));
272
295
  }
296
+ function readIosAppPid(node, context) {
297
+ const env = context.env ?? {};
298
+ const device = node.simulator ?? node.ios_simulator ?? env.SIM_UDID ?? env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
299
+ const bundleId = node.bundle_id ?? node.bundleId ?? env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
300
+ if (typeof device !== "string" || typeof bundleId !== "string") return null;
301
+ try {
302
+ const output = execFileSync(
303
+ "xcrun",
304
+ ["simctl", "spawn", device, "launchctl", "list"],
305
+ { encoding: "utf8", timeout: 5e3 }
306
+ );
307
+ const labelPrefix = `UIKitApplication:${bundleId}[`;
308
+ const line = output.split("\n").find((candidate) => candidate.includes(labelPrefix));
309
+ if (!line) return null;
310
+ const pid = Number.parseInt(line.trim().split(/\s+/u)[0] ?? "", 10);
311
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
273
316
  function isAndroidLifecycle(node, env) {
274
317
  const platform = node.platform ?? env.PLATFORM ?? process.env.PLATFORM;
275
318
  if (platform === "android") return true;
@@ -299,10 +342,9 @@ function mergeAndroidRestartResults(initialResult, foregroundResult) {
299
342
  }
300
343
  async function probeMobileLifecycleRuntime(node, context) {
301
344
  const command = node.command ?? node.event ?? node.state;
302
- const defaultTimeoutMs = command === "restart" || command === "launch" ? 6e4 : 3e4;
303
- const timeoutMs = lifecycleReadinessTimeout(
304
- node.runtime_ready_timeout_ms,
305
- defaultTimeoutMs
345
+ const timeoutMs = resolveMobileLifecycleReadinessTimeout(
346
+ command,
347
+ node.runtime_ready_timeout_ms
306
348
  );
307
349
  const deadline = Date.now() + timeoutMs;
308
350
  let lastError;
@@ -328,8 +370,10 @@ async function probeMobileLifecycleRuntime(node, context) {
328
370
  `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
329
371
  );
330
372
  }
331
- function lifecycleReadinessTimeout(value, fallback) {
332
- if (value === void 0) return fallback;
373
+ function resolveMobileLifecycleReadinessTimeout(command, value) {
374
+ if (value === void 0) {
375
+ return command === "launch" || command === "restart" || command === "foreground" ? 6e4 : 3e4;
376
+ }
333
377
  const timeout = Number(value);
334
378
  if (!Number.isInteger(timeout) || timeout < 1e3 || timeout > 12e4) {
335
379
  throw new Error(
@@ -364,5 +408,6 @@ export {
364
408
  createMetaMaskMobileRunner,
365
409
  createMetaMaskRunner,
366
410
  isAutomaticHudProgress,
367
- mobileSourceAwareLifecycleAdapters
411
+ mobileSourceAwareLifecycleAdapters,
412
+ resolveMobileLifecycleReadinessTimeout
368
413
  };
@@ -244,6 +244,7 @@ function persistExplicitDevicePin(target, device) {
244
244
  return;
245
245
  }
246
246
  const next = { ...existing };
247
+ next.platform = device.platform;
247
248
  if (device.platform === "ios") {
248
249
  const simulatorName = device.name ? device.name.replace(/_/gu, " ").trim() : device.id;
249
250
  next.simulator = simulatorName;
@@ -0,0 +1,4 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { measureHomepageVisible } from './perps.mjs';
3
+
4
+ runAdapter(measureHomepageVisible);