@deeeed/metamask-harness 0.44.0 → 0.44.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.44.2 - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Wait for a new React Native runtime after an authored Mobile restart, and avoid a second Android foreground launch when that runtime is already ready.
10
+ - Hold Mobile restart recovery inside one readiness deadline: the foreground relaunch and its subprocesses inherit the remaining budget, and an expired deadline fails the lifecycle action instead of starting another launch or readiness probe.
11
+ - Read the selected Mobile target identity and its broker generation for `status-selected` without attaching to CDP.
12
+
13
+ ## 0.44.1 - 2026-08-27
14
+
15
+ ### Fixed
16
+
17
+ - Accept retained market context as lifecycle-valid Homepage evidence during account-only switches.
18
+ - Require controller-backed Mobile unlock proof before continuing a recipe after startup or restart.
19
+
5
20
  ## 0.44.0 - 2026-08-25
6
21
 
7
22
  ### Fixed
@@ -30,9 +30,11 @@ const {
30
30
  deviceIdFromUrl,
31
31
  } = require('./lib/cdp-broker.cjs');
32
32
  const {
33
+ BRIDGE_ERROR_CODES,
33
34
  EXIT_CODE_BY_ERROR_CODE,
34
35
  TEACHING_BY_ERROR_CODE,
35
36
  classifyBridgeErrorMessage,
37
+ coded,
36
38
  formatErrorMarker,
37
39
  } = require('./lib/bridge-errors.cjs');
38
40
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
@@ -293,6 +295,9 @@ function parseHudStep(args) {
293
295
  }
294
296
 
295
297
  const COMMANDS = {
298
+ async 'target-identity-selected'() {
299
+ throw new Error('target-identity-selected is resolved before CDP attachment');
300
+ },
296
301
  async navigate(client, args, { deviceName, platform } = {}) {
297
302
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
298
303
  if (!routeName) {
@@ -413,16 +418,32 @@ const COMMANDS = {
413
418
  return { currentRoute: route ?? null, deviceName, platform };
414
419
  },
415
420
 
416
- async status(client, _args, { deviceName, platform } = {}) {
421
+ async status(
422
+ client,
423
+ _args,
424
+ { deviceName, platform, runtimeIdentity } = {},
425
+ ) {
417
426
  const expr = `(function() {
418
427
  var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
419
428
  var route = globalThis.__AGENTIC__?.getRoute() || null;
420
429
  var account = null;
430
+ var keyringUnlocked = false;
421
431
  try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
422
- return { route: route, account: account, agenticPresent: agenticPresent };
432
+ try { keyringUnlocked = globalThis.Engine?.context?.KeyringController?.isUnlocked?.() === true; } catch(e) {}
433
+ return {
434
+ route: route,
435
+ account: account,
436
+ agenticPresent: agenticPresent,
437
+ keyringUnlocked: keyringUnlocked,
438
+ };
423
439
  })()`;
424
440
  const snapshot = await cdpEval(client, expr);
425
- return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
441
+ return {
442
+ ...snapshot,
443
+ ...(runtimeIdentity ? { runtimeIdentity } : {}),
444
+ deviceName: deviceName || '',
445
+ platform: platform || '',
446
+ };
426
447
  },
427
448
 
428
449
  async 'list-accounts'(client) {
@@ -1352,7 +1373,10 @@ Environment:
1352
1373
  ).trim();
1353
1374
  }
1354
1375
 
1355
- async function brokerTargets({ retainIdentity = false } = {}) {
1376
+ async function brokerTargets({
1377
+ retainIdentity = false,
1378
+ includeGeneration = false,
1379
+ } = {}) {
1356
1380
  const discoveryClient = await createBrokerClient(
1357
1381
  brokerSocketFile,
1358
1382
  '',
@@ -1360,14 +1384,22 @@ Environment:
1360
1384
  );
1361
1385
  try {
1362
1386
  const targets = await discoveryClient.control(
1363
- retainIdentity ? 'resolve-targets' : 'list-targets',
1387
+ retainIdentity
1388
+ ? 'resolve-targets'
1389
+ : includeGeneration
1390
+ ? 'list-target-identities'
1391
+ : 'list-targets',
1364
1392
  retainIdentity ? { nameIncludes: selectedTargetPin() } : {},
1365
1393
  timeout,
1366
1394
  );
1367
1395
  return (Array.isArray(targets) ? targets : []).flatMap((target) => {
1368
1396
  const deviceId = String(target?.deviceId || '');
1369
1397
  return deviceId
1370
- ? [{ wsUrl: deviceId, deviceName: String(target?.name || '') }]
1398
+ ? [{
1399
+ wsUrl: deviceId,
1400
+ deviceName: String(target?.name || ''),
1401
+ generation: Number(target?.generation) || 0,
1402
+ }]
1371
1403
  : [];
1372
1404
  });
1373
1405
  } finally {
@@ -1383,13 +1415,31 @@ Environment:
1383
1415
  )
1384
1416
  : targets;
1385
1417
  if (candidates.length !== 1) {
1386
- throw new Error(
1418
+ const error = new Error(
1387
1419
  `Mobile CDP broker target selection requires one target; found ${candidates.length}${pin ? ` for ${JSON.stringify(pin)}` : ''}.`,
1388
1420
  );
1421
+ throw candidates.length === 0
1422
+ ? coded(error, BRIDGE_ERROR_CODES.NO_TARGET)
1423
+ : error;
1389
1424
  }
1390
1425
  return candidates[0];
1391
1426
  }
1392
1427
 
1428
+ if (command === 'target-identity-selected') {
1429
+ const target = brokerAvailable
1430
+ ? selectBrokerTarget(await brokerTargets({ includeGeneration: true }))
1431
+ : await discoverTarget(port, { probe: true });
1432
+ const generation = Number(target.generation) || 0;
1433
+ console.log(
1434
+ JSON.stringify({
1435
+ runtimeIdentity: generation
1436
+ ? `${target.wsUrl}:${generation}`
1437
+ : target.wsUrl,
1438
+ }),
1439
+ );
1440
+ return;
1441
+ }
1442
+
1393
1443
  async function clientFor(wsUrl) {
1394
1444
  if (brokerAvailable) {
1395
1445
  return createBrokerClient(
@@ -1432,9 +1482,10 @@ Environment:
1432
1482
  return;
1433
1483
  }
1434
1484
 
1435
- const { wsUrl, deviceName } = brokerAvailable
1485
+ const selectedTarget = brokerAvailable
1436
1486
  ? selectBrokerTarget(await brokerTargets({ retainIdentity: true }))
1437
1487
  : await discoverTarget(port, { probe: true });
1488
+ const { wsUrl, deviceName, generation } = selectedTarget;
1438
1489
  if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1439
1490
  const client = await clientFor(wsUrl);
1440
1491
 
@@ -1442,7 +1493,28 @@ Environment:
1442
1493
  const platform = command.endsWith('-deferred')
1443
1494
  ? ''
1444
1495
  : await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1445
- const result = await handler(client, args.slice(1), { deviceName, platform });
1496
+ const runtimeIdentity =
1497
+ command === 'status-selected'
1498
+ ? generation
1499
+ ? `${wsUrl}:${generation}`
1500
+ : wsUrl
1501
+ : undefined;
1502
+ const result = await handler(client, args.slice(1), {
1503
+ deviceName,
1504
+ platform,
1505
+ runtimeIdentity,
1506
+ });
1507
+ if (brokerAvailable && command === 'status-selected') {
1508
+ const currentTarget = selectBrokerTarget(
1509
+ await brokerTargets({ includeGeneration: true }),
1510
+ );
1511
+ if (
1512
+ currentTarget.wsUrl !== wsUrl ||
1513
+ currentTarget.generation !== generation
1514
+ ) {
1515
+ throw new Error('Hermes runtime rotated during status-selected');
1516
+ }
1517
+ }
1446
1518
  console.log(JSON.stringify(result, null, 2));
1447
1519
  } finally {
1448
1520
  client.close();
@@ -59,6 +59,7 @@ const NEEDLE_CODES = [
59
59
  ['did not match any metro target', BRIDGE_ERROR_CODES.NO_TARGET],
60
60
  ['pinned android device', BRIDGE_ERROR_CODES.NO_TARGET],
61
61
  ['no react native bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
62
+ ['cdp broker target unavailable', BRIDGE_ERROR_CODES.NO_TARGET],
62
63
  ['websocket closed', BRIDGE_ERROR_CODES.WS_CLOSED],
63
64
  ['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
64
65
  ['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
@@ -613,6 +613,11 @@ function createCdpBroker({
613
613
  name,
614
614
  }));
615
615
  }
616
+ if (action === 'list-target-identities') {
617
+ return targetList({ readyOnly: true }).map(
618
+ ({ deviceId, generation, name }) => ({ deviceId, generation, name }),
619
+ );
620
+ }
616
621
  if (action === 'resolve-targets') {
617
622
  return resolveTargets(params, timeoutMs, socket);
618
623
  }
@@ -1,3 +1,56 @@
1
+ import { execFile } from "node:child_process";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { promisify } from "node:util";
4
+ const MOBILE_LIFECYCLE_DEADLINE_FIELD = "_mobile_lifecycle_deadline_epoch_ms";
5
+ const lifecycleDeadline = new AsyncLocalStorage();
6
+ const execFileAsync = promisify(execFile);
7
+ function bindMetaMaskMobileLifecycleDeadline(adapters) {
8
+ return adapters.map((adapter) => ({
9
+ ...adapter,
10
+ execute(node, context) {
11
+ const deadline = Number(node[MOBILE_LIFECYCLE_DEADLINE_FIELD]);
12
+ if (!Number.isFinite(deadline)) return adapter.execute(node, context);
13
+ return lifecycleDeadline.run(deadline, () => adapter.execute(node, context));
14
+ }
15
+ }));
16
+ }
17
+ function createMetaMaskMobileLifecycleCommandRunner(base = defaultLifecycleCommandRunner()) {
18
+ return {
19
+ execFile(file, args, options) {
20
+ const deadline = lifecycleDeadline.getStore();
21
+ if (deadline === void 0) return base.execFile(file, args, options);
22
+ const remainingMs = deadline - Date.now();
23
+ if (remainingMs <= 0) {
24
+ throw new Error(
25
+ `Mobile lifecycle deadline expired before ${file} could start.`
26
+ );
27
+ }
28
+ const configuredTimeoutMs = options?.timeoutMs;
29
+ return base.execFile(file, args, {
30
+ timeoutMs: configuredTimeoutMs === void 0 ? remainingMs : Math.min(configuredTimeoutMs, remainingMs)
31
+ });
32
+ }
33
+ };
34
+ }
35
+ function defaultLifecycleCommandRunner() {
36
+ return {
37
+ async execFile(file, args, options) {
38
+ try {
39
+ return await execFileAsync(file, args, {
40
+ timeout: options?.timeoutMs,
41
+ encoding: "utf8"
42
+ });
43
+ } catch (error) {
44
+ if (error instanceof Error) {
45
+ const output = error;
46
+ const details = [output.message, output.stderr, output.stdout].filter(Boolean).join("\n");
47
+ throw new Error(`${file} ${args.join(" ")} failed: ${details}`);
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ };
53
+ }
1
54
  function resolveMetaMaskMobileLifecycleTarget(node, rawContext) {
2
55
  const context = rawContext;
3
56
  const platform = resolveMobilePlatform(node, context);
@@ -69,5 +122,8 @@ function scalar(value, label) {
69
122
  throw new Error(`${label} must be a string, number, or boolean.`);
70
123
  }
71
124
  export {
125
+ MOBILE_LIFECYCLE_DEADLINE_FIELD,
126
+ bindMetaMaskMobileLifecycleDeadline,
127
+ createMetaMaskMobileLifecycleCommandRunner,
72
128
  resolveMetaMaskMobileLifecycleTarget
73
129
  };
package/dist/runner.js CHANGED
@@ -1,7 +1,10 @@
1
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
- import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
4
+ import {
5
+ bridgeCommand,
6
+ MOBILE_BRIDGE_ERROR_CODES
7
+ } from "../library/actions/mobile/platform/bridge.mjs";
5
8
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
6
9
  import {
7
10
  mobileSourceFingerprint,
@@ -13,7 +16,12 @@ import {
13
16
  createAndroidVideoRecorder,
14
17
  createIosSimulatorVideoRecorder
15
18
  } from "./adapters/mobile/video-recorder.js";
16
- import { resolveMetaMaskMobileLifecycleTarget } from "./app-lifecycle.js";
19
+ import {
20
+ bindMetaMaskMobileLifecycleDeadline,
21
+ createMetaMaskMobileLifecycleCommandRunner,
22
+ MOBILE_LIFECYCLE_DEADLINE_FIELD,
23
+ resolveMetaMaskMobileLifecycleTarget
24
+ } from "./app-lifecycle.js";
17
25
  import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from "./manifest.js";
18
26
  import { metaMaskActionExecutionCapabilities } from "./recipe-security.js";
19
27
  import {
@@ -84,10 +92,13 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
84
92
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
85
93
  const lifecycle = mobileSourceAwareLifecycleAdapters(
86
94
  adapter,
87
- createAppLifecycleAdapters({
88
- actions,
89
- targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
90
- })
95
+ bindMetaMaskMobileLifecycleDeadline(
96
+ createAppLifecycleAdapters({
97
+ actions,
98
+ commandRunner: createMetaMaskMobileLifecycleCommandRunner(),
99
+ targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
100
+ })
101
+ )
91
102
  );
92
103
  const custom = createMetaMaskAdapters(
93
104
  adapter,
@@ -337,7 +348,7 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
337
348
  }, processIdentity = {
338
349
  readIosPid: readIosAppPid,
339
350
  readAndroidPid: readAndroidAppPid
340
- }) {
351
+ }, runtimeMarker = readMobileLifecycleRuntimeIdentity) {
341
352
  if (adapter !== "mobile") return lifecycle;
342
353
  return lifecycle.map((entry) => ({
343
354
  ...entry,
@@ -357,19 +368,62 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
357
368
  const readProcessId = androidLifecycle ? processIdentity.readAndroidPid ?? readAndroidAppPid : processIdentity.readIosPid;
358
369
  const processBefore = recordsProcessIdentity ? readProcessId(node, context) : void 0;
359
370
  const fingerprint = recordsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
371
+ const previousRuntimeIdentity = recordsSource ? await runtimeMarker(node, context) : void 0;
360
372
  const androidRestart = command === "restart" && androidLifecycle;
361
- const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
362
- const initialResult = await entry.execute(initialNode, context);
373
+ const initialResult = await entry.execute(node, context);
374
+ const readinessDeadline = reloadsSource ? mobileLifecycleReadinessDeadline(command, node) : void 0;
363
375
  let result = initialResult;
376
+ let runtimeReady = false;
364
377
  if (androidRestart) {
365
- const foregroundResult = await entry.execute(
366
- { ...node, command: "foreground" },
367
- context
368
- );
369
- result = mergeAndroidRestartResults(initialResult, foregroundResult);
378
+ try {
379
+ const remainingMs = Math.max(
380
+ 1,
381
+ (readinessDeadline ?? Date.now()) - Date.now()
382
+ );
383
+ const handoffDeadline = Math.min(
384
+ readinessDeadline ?? Number.POSITIVE_INFINITY,
385
+ Date.now() + Math.min(1e4, Math.max(1, remainingMs / 2))
386
+ );
387
+ await readinessProbe(
388
+ node,
389
+ context,
390
+ previousRuntimeIdentity,
391
+ handoffDeadline
392
+ );
393
+ runtimeReady = true;
394
+ } catch (error) {
395
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
396
+ throw error;
397
+ }
398
+ const foregroundTimeoutMs = Math.max(
399
+ 1,
400
+ (readinessDeadline ?? Date.now() + 1) - Date.now()
401
+ );
402
+ const foregroundResult = await entry.execute(
403
+ {
404
+ ...node,
405
+ command: "foreground",
406
+ settle_ms: 0,
407
+ timeout_ms: foregroundTimeoutMs,
408
+ [MOBILE_LIFECYCLE_DEADLINE_FIELD]: readinessDeadline
409
+ },
410
+ context
411
+ );
412
+ result = mergeAndroidRestartResults(initialResult, foregroundResult);
413
+ }
370
414
  }
371
- if (reloadsSource) {
372
- await readinessProbe(node, context);
415
+ if (reloadsSource && !runtimeReady) {
416
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
417
+ throw new Error(
418
+ `Mobile lifecycle ${String(command)} exhausted its runtime readiness deadline before recovery completed.`
419
+ );
420
+ }
421
+ await readinessProbe(
422
+ node,
423
+ context,
424
+ previousRuntimeIdentity,
425
+ readinessDeadline
426
+ );
373
427
  }
374
428
  if (recordsProcessIdentity) {
375
429
  const processAfter = readProcessId(node, context);
@@ -468,35 +522,92 @@ function mergeAndroidRestartResults(initialResult, foregroundResult) {
468
522
  }
469
523
  };
470
524
  }
471
- async function probeMobileLifecycleRuntime(node, context) {
525
+ async function probeMobileLifecycleRuntime(node, context, previousRuntimeIdentity, deadlineMs) {
472
526
  const command = node.command ?? node.event ?? node.state;
473
527
  const timeoutMs = resolveMobileLifecycleReadinessTimeout(
474
528
  command,
475
529
  node.runtime_ready_timeout_ms
476
530
  );
477
- const deadline = Date.now() + timeoutMs;
531
+ const startedAt = Date.now();
532
+ const deadline = deadlineMs ?? startedAt + timeoutMs;
478
533
  let lastError;
479
534
  while (Date.now() < deadline) {
480
535
  try {
481
- return await bridgeCommand(
536
+ const status = await bridgeCommand(
482
537
  {
483
538
  action: "app.lifecycle",
484
539
  node: {
485
540
  ...node,
486
- cdp_timeout_ms: Math.min(8e3, Math.max(1e3, deadline - Date.now()))
541
+ cdp_timeout_ms: Math.min(8e3, Math.max(1, deadline - Date.now()))
487
542
  },
488
543
  context
489
544
  },
490
- ["get-route"]
545
+ ["status-selected"]
546
+ );
547
+ if (status?.agenticPresent === true && status.route && (!previousRuntimeIdentity || status.runtimeIdentity !== previousRuntimeIdentity)) {
548
+ return status;
549
+ }
550
+ lastError = new Error(
551
+ previousRuntimeIdentity && status?.runtimeIdentity === previousRuntimeIdentity ? "retiring Mobile runtime is still selected" : "selected Mobile runtime is not ready"
491
552
  );
492
553
  } catch (error) {
493
554
  lastError = error;
494
- await new Promise((resolve) => setTimeout(resolve, 1e3));
555
+ }
556
+ const remainingMs = deadline - Date.now();
557
+ if (remainingMs > 0) {
558
+ await new Promise(
559
+ (resolve) => setTimeout(resolve, Math.min(1e3, remainingMs))
560
+ );
495
561
  }
496
562
  }
497
563
  throw new Error(
498
- `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
564
+ `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${Math.max(0, deadline - startedAt)}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
565
+ );
566
+ }
567
+ async function readMobileLifecycleRuntimeIdentity(node, context) {
568
+ const deadline = Date.now() + 5e3;
569
+ let lastError;
570
+ do {
571
+ try {
572
+ const status = await bridgeCommand(
573
+ {
574
+ action: "app.lifecycle",
575
+ node: {
576
+ ...node,
577
+ cdp_timeout_ms: Math.min(1e3, Math.max(1, deadline - Date.now()))
578
+ },
579
+ context
580
+ },
581
+ ["target-identity-selected"]
582
+ );
583
+ if (typeof status?.runtimeIdentity === "string") {
584
+ return status.runtimeIdentity;
585
+ }
586
+ lastError = new Error("selected Mobile runtime has no stable identity");
587
+ } catch (error) {
588
+ if (error instanceof Error && "code" in error && error.code === MOBILE_BRIDGE_ERROR_CODES.NO_TARGET) {
589
+ return void 0;
590
+ }
591
+ lastError = error;
592
+ }
593
+ const remainingMs = deadline - Date.now();
594
+ if (remainingMs <= 0) {
595
+ break;
596
+ }
597
+ await new Promise(
598
+ (resolve) => setTimeout(resolve, Math.min(250, remainingMs))
599
+ );
600
+ } while (Date.now() < deadline);
601
+ throw new Error(
602
+ `Mobile lifecycle restart could not identify the retiring runtime: ${lastError instanceof Error ? lastError.message : String(lastError)}`
603
+ );
604
+ }
605
+ function mobileLifecycleReadinessDeadline(command, node) {
606
+ const timeoutMs = resolveMobileLifecycleReadinessTimeout(
607
+ command,
608
+ node.runtime_ready_timeout_ms
499
609
  );
610
+ return Date.now() + timeoutMs;
500
611
  }
501
612
  function resolveMobileLifecycleReadinessTimeout(command, value) {
502
613
  if (value === void 0) {
@@ -680,6 +680,13 @@ function frameTime(record) {
680
680
 
681
681
  function frameIsFresh(record) {
682
682
  if (record.fresh_for_lifecycle === false) return false;
683
+ if (record.source === "retained_market_context") {
684
+ return (
685
+ record.lifecycle === "account_switch" &&
686
+ !isAccountContentVariant(record.content_variant) &&
687
+ record.fresh_for_lifecycle === true
688
+ );
689
+ }
683
690
  return (
684
691
  FRESH_SOURCES.has(record.source) ||
685
692
  ((record.source === "resident_state" || record.source === "memory_cache") &&
@@ -94,7 +94,7 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
94
94
  const deadline = Date.now() + timeoutMs;
95
95
  let last = initialStatus;
96
96
  while (true) {
97
- if (selectedAccount(last, input) || routeName(last, input) === 'Login') return last;
97
+ if (isUnlockedStatus(last, input) || routeName(last, input) === 'Login') return last;
98
98
  if (Date.now() >= deadline) break;
99
99
  await new Promise((resolve) => setTimeout(resolve, 250));
100
100
  try {
@@ -104,6 +104,11 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
104
104
  // still bounds this loop and the final error reports the last wallet state.
105
105
  }
106
106
  }
107
+ if (selectedAccount(last, input)) {
108
+ throw new Error(
109
+ `Mobile wallet route and controller unlock state did not settle within the remaining ${timeoutMs}ms target budget (route ${routeName(last, input) || 'unknown'}).`,
110
+ );
111
+ }
107
112
  throw new Error(
108
113
  `No wallet onboarded on this device (route ${routeName(last, input) || 'unknown'}, empty wallet status) — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked`,
109
114
  );
@@ -116,7 +121,7 @@ async function waitForUnlocked(input, timeoutMs = 30000) {
116
121
  while (Date.now() < deadline) {
117
122
  try {
118
123
  last = await statusBeforeDeadline(input, deadline);
119
- if (selectedAccount(last, input) && routeName(last, input) !== 'Login') return last;
124
+ if (isUnlockedStatus(last, input)) return last;
120
125
  } catch (error) {
121
126
  lastError = error;
122
127
  }
@@ -126,7 +131,14 @@ async function waitForUnlocked(input, timeoutMs = 30000) {
126
131
  }
127
132
 
128
133
  function isUnlockedStatus(status, input) {
129
- return Boolean(selectedAccount(status, input)) && routeName(status, input) !== 'Login';
134
+ const selected = selectedStatus(status, input);
135
+ const route = routeName(status, input);
136
+ return (
137
+ Boolean(selectedAccount(status, input)) &&
138
+ selected?.keyringUnlocked === true &&
139
+ route.length > 0 &&
140
+ route !== 'Login'
141
+ );
130
142
  }
131
143
 
132
144
  async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
@@ -188,6 +200,8 @@ export async function ensureUnlocked(input) {
188
200
  action: input.action,
189
201
  unlocked: true,
190
202
  alreadyUnlocked: true,
203
+ keyringUnlocked:
204
+ selectedStatus(stableBefore, input)?.keyringUnlocked === true,
191
205
  account: selectedAccount(stableBefore, input),
192
206
  deviceName: selectedStatus(stableBefore, input)?.deviceName ?? null,
193
207
  platform: selectedStatus(stableBefore, input)?.platform ?? null,
@@ -209,11 +223,13 @@ export async function ensureUnlocked(input) {
209
223
  }
210
224
  try {
211
225
  const current = await statusBeforeDeadline(input, unlockDeadline);
212
- if (selectedAccount(current, input) && routeName(current, input) !== 'Login') {
226
+ if (isUnlockedStatus(current, input)) {
213
227
  return {
214
228
  action: input.action,
215
229
  unlocked: true,
216
230
  alreadyUnlocked: true,
231
+ keyringUnlocked:
232
+ selectedStatus(current, input)?.keyringUnlocked === true,
217
233
  account: selectedAccount(current, input),
218
234
  deviceName: selectedStatus(current, input)?.deviceName ?? null,
219
235
  platform: selectedStatus(current, input)?.platform ?? null,
@@ -246,6 +262,8 @@ export async function ensureUnlocked(input) {
246
262
  return {
247
263
  action: input.action,
248
264
  unlocked: Boolean(result?.ok ?? result?.unlocked ?? true),
265
+ keyringUnlocked:
266
+ selectedStatus(stableAfter, input)?.keyringUnlocked === true,
249
267
  account: selectedAccount(stableAfter, input),
250
268
  route: selectedStatus(stableAfter, input)?.route ?? null,
251
269
  deviceName: selectedStatus(stableAfter, input)?.deviceName ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.44.0",
3
+ "version": "0.44.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"