@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
@@ -1,11 +1,92 @@
1
1
  import { pathToFileURL } from 'node:url';
2
- import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ bridgeCommand,
4
+ evalAsync,
5
+ navigate,
6
+ runAdapter,
7
+ } from '../platform/bridge.mjs';
3
8
  import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
4
9
 
5
10
  function sleep(ms) {
6
11
  return new Promise((resolve) => setTimeout(resolve, ms));
7
12
  }
8
13
 
14
+ export async function measureHomepageVisible(input) {
15
+ const wallet = await ensureUnlocked({
16
+ ...input,
17
+ action: 'metamask.wallet.ensure_unlocked',
18
+ });
19
+ const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
20
+ const targetTestId = input.node?.target_test_id;
21
+ const targetText = input.node?.target_text;
22
+ if (Number(Boolean(targetTestId)) + Number(Boolean(targetText)) !== 1) {
23
+ throw new Error(
24
+ 'metamask.perps.measure_homepage_visible requires exactly one target_test_id or target_text.',
25
+ );
26
+ }
27
+
28
+ const options = {
29
+ animated: input.node?.animated === true,
30
+ fromTestId: input.node?.from_test_id ?? 'wallet-screen',
31
+ offset: Number(input.node?.offset ?? 600),
32
+ pollIntervalMs: Number(input.node?.poll_interval_ms ?? 100),
33
+ requireWalletReady: true,
34
+ timeoutMs,
35
+ scrollTestId: input.node?.scroll_test_id ?? 'wallet-screen',
36
+ ...(targetTestId ? { targetTestId } : { targetText }),
37
+ ...(input.node?.required_present_test_id
38
+ ? { requiredPresentTestId: input.node.required_present_test_id }
39
+ : {}),
40
+ ...(input.node?.visible_event_stage
41
+ ? { visibleEventStage: input.node.visible_event_stage }
42
+ : {}),
43
+ ...(input.node?.visible_event_grace_ms !== undefined
44
+ ? { visibleEventGraceMs: Number(input.node.visible_event_grace_ms) }
45
+ : {}),
46
+ };
47
+ const measurement = await bridgeCommand(
48
+ {
49
+ ...input,
50
+ node: {
51
+ ...input.node,
52
+ bridge_timeout_ms: timeoutMs + 15_000,
53
+ cdp_timeout_ms: timeoutMs + 15_000,
54
+ },
55
+ },
56
+ ['measure-scroll-transition', JSON.stringify(options)],
57
+ );
58
+ if (measurement?.ok !== true) {
59
+ throw new Error(
60
+ `metamask.perps.measure_homepage_visible failed: ${String(measurement?.error ?? 'unknown measurement error')}; measurement=${JSON.stringify(measurement)}.`,
61
+ );
62
+ }
63
+ return {
64
+ action: input.action,
65
+ wallet,
66
+ measurement,
67
+ proofPath: 'direct-cdp-wallet-ready-to-visible',
68
+ };
69
+ }
70
+
71
+ export async function retryPerpsClientRead(
72
+ read,
73
+ { timeoutMs = 20000, intervalMs = 500, sleep: wait = sleep } = {},
74
+ ) {
75
+ const deadline = Date.now() + timeoutMs;
76
+ while (true) {
77
+ try {
78
+ return await read();
79
+ } catch (error) {
80
+ if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
81
+ throw error;
82
+ }
83
+ const remainingMs = deadline - Date.now();
84
+ if (remainingMs <= 0) throw error;
85
+ await wait(Math.min(intervalMs, remainingMs));
86
+ }
87
+ }
88
+ }
89
+
9
90
  function marketSymbol(input) {
10
91
  return normalizeMarketSymbol(input.node?.market ?? input.node?.symbol ?? 'BTC');
11
92
  }
@@ -104,54 +185,155 @@ function uniqueSymbols(symbols) {
104
185
  }
105
186
 
106
187
  async function readPositions(input) {
107
- const positions = await evalAsync(
108
- input,
109
- `(function(){
110
- var controller = Engine && Engine.context && Engine.context.PerpsController;
111
- if (!controller || typeof controller.getPositions !== 'function') {
112
- throw new Error('Engine.context.PerpsController.getPositions is unavailable; cannot assert live Perps positions.');
188
+ return retryPerpsClientRead(
189
+ async () => {
190
+ const positions = await evalAsync(
191
+ input,
192
+ `(function(){
193
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
194
+ if (!controller || typeof controller.getPositions !== 'function') {
195
+ throw new Error('Engine.context.PerpsController.getPositions is unavailable; cannot assert live Perps positions.');
196
+ }
197
+ return controller.getPositions().then(function(r){
198
+ if (!Array.isArray(r)) throw new Error('PerpsController.getPositions returned a non-array result.');
199
+ return JSON.stringify(r);
200
+ });
201
+ })()`,
202
+ );
203
+ if (!Array.isArray(positions)) {
204
+ throw new Error(
205
+ 'PerpsController.getPositions returned a non-array result.',
206
+ );
113
207
  }
114
- return controller.getPositions().then(function(r){
115
- if (!Array.isArray(r)) throw new Error('PerpsController.getPositions returned a non-array result.');
116
- return JSON.stringify(r);
117
- });
118
- })()`,
208
+ return positions;
209
+ },
119
210
  );
120
- if (!Array.isArray(positions)) throw new Error('PerpsController.getPositions returned a non-array result.');
121
- return positions;
122
211
  }
123
212
 
124
213
  async function readOpenOrders(input) {
125
- const orders = await evalAsync(
126
- input,
127
- `(function(){
128
- var controller = Engine && Engine.context && Engine.context.PerpsController;
129
- if (!controller || typeof controller.getOpenOrders !== 'function') {
130
- throw new Error('Engine.context.PerpsController.getOpenOrders is unavailable; cannot assert live Perps orders.');
214
+ return retryPerpsClientRead(
215
+ async () => {
216
+ const orders = await evalAsync(
217
+ input,
218
+ `(function(){
219
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
220
+ if (!controller || typeof controller.getOpenOrders !== 'function') {
221
+ throw new Error('Engine.context.PerpsController.getOpenOrders is unavailable; cannot assert live Perps orders.');
222
+ }
223
+ return controller.getOpenOrders().then(function(r){
224
+ if (!Array.isArray(r)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
225
+ return JSON.stringify(r);
226
+ });
227
+ })()`,
228
+ );
229
+ if (!Array.isArray(orders)) {
230
+ throw new Error(
231
+ 'PerpsController.getOpenOrders returned a non-array result.',
232
+ );
131
233
  }
132
- return controller.getOpenOrders().then(function(r){
133
- if (!Array.isArray(r)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
134
- return JSON.stringify(r);
135
- });
136
- })()`,
234
+ return orders;
235
+ },
137
236
  );
138
- if (!Array.isArray(orders)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
139
- return orders;
140
237
  }
141
238
 
142
- async function clearPerformanceCaches(input) {
143
- return evalAsync(
144
- input,
145
- `(function(){
146
- var bridge = globalThis.__AGENTIC__;
147
- if (!bridge || typeof bridge.clearPerpsPerformanceCaches !== 'function') {
148
- throw new Error('__AGENTIC__.clearPerpsPerformanceCaches is unavailable; reload the current Mobile development source.');
239
+ export function clearPerformanceCachesExpression() {
240
+ return `(function(){
241
+ var metroRequire = globalThis.__r;
242
+ if (typeof metroRequire !== 'function' || typeof metroRequire.getModules !== 'function') {
243
+ throw new Error('Metro module registry is unavailable; cannot clear Perps performance caches through CDP.');
149
244
  }
150
- return Promise.resolve(bridge.clearPerpsPerformanceCaches()).then(function(result){
151
- return JSON.stringify(result);
245
+ var storageModuleId = null;
246
+ var perpsConfigModuleId = null;
247
+ var streamManagerModuleId = null;
248
+ metroRequire.getModules().forEach(function(module, moduleId){
249
+ var name = String(module && module.verboseName || '');
250
+ if (name === 'app/store/storage-wrapper.ts' || name.endsWith('/app/store/storage-wrapper.ts')) {
251
+ storageModuleId = moduleId;
252
+ }
253
+ if (name.includes('@metamask/perps-controller') && name.includes('/constants/perpsConfig.')) {
254
+ perpsConfigModuleId = moduleId;
255
+ }
256
+ if (name === 'app/components/UI/Perps/providers/PerpsStreamManager.tsx' || name.endsWith('/app/components/UI/Perps/providers/PerpsStreamManager.tsx')) {
257
+ streamManagerModuleId = moduleId;
258
+ }
152
259
  });
153
- })()`,
154
- );
260
+ if (storageModuleId === null || perpsConfigModuleId === null || streamManagerModuleId === null) {
261
+ throw new Error('Required Mobile storage, Perps constants, or stream manager module is unavailable in Metro.');
262
+ }
263
+ var storageExports = metroRequire(storageModuleId);
264
+ var storage = storageExports && (storageExports.default || storageExports);
265
+ var perpsConfig = metroRequire(perpsConfigModuleId);
266
+ var streamManagerExports = metroRequire(streamManagerModuleId);
267
+ var getStreamManager = streamManagerExports && streamManagerExports.getStreamManagerInstance;
268
+ var streamManager = typeof getStreamManager === 'function' ? getStreamManager() : null;
269
+ var keys = [
270
+ perpsConfig.PERPS_DISK_CACHE_MARKETS,
271
+ perpsConfig.PERPS_DISK_CACHE_USER_DATA,
272
+ ];
273
+ if (!storage || typeof storage.removeItem !== 'function' || typeof storage.getItem !== 'function' || keys.some(function(key){ return typeof key !== 'string' || key.length === 0; })) {
274
+ throw new Error('Resolved Mobile storage or Perps cache constants are invalid.');
275
+ }
276
+ if (!streamManager) {
277
+ throw new Error('Resolved Mobile Perps stream manager is invalid.');
278
+ }
279
+ var engine = globalThis.Engine;
280
+ var controller = engine && engine.context && engine.context.PerpsController;
281
+ if (!controller || typeof controller.update !== 'function' || !controller.state) {
282
+ throw new Error('Engine.context.PerpsController cache state is unavailable.');
283
+ }
284
+ var marketEntries = Object.keys(controller.state.cachedMarketDataByProvider || {}).length;
285
+ var userEntries = Object.keys(controller.state.cachedUserDataByProvider || {}).length;
286
+ var channelNames = ['marketData', 'positions', 'orders', 'account'];
287
+ var quiesceChannels = function(){
288
+ channelNames.forEach(function(name){
289
+ var channel = streamManager[name];
290
+ if (!channel || typeof channel.disconnect !== 'function' || typeof channel.clearCache !== 'function') {
291
+ throw new Error('Perps stream channel ' + name + ' cannot be quiesced.');
292
+ }
293
+ channel.disconnect();
294
+ });
295
+ };
296
+ quiesceChannels();
297
+ if (typeof controller.stopMarketDataPreload === 'function') {
298
+ controller.stopMarketDataPreload();
299
+ }
300
+ var pendingOperations = [
301
+ streamManager.marketData && streamManager.marketData.fetchPromise,
302
+ streamManager.userDataSnapshotPromise,
303
+ ].filter(function(value){ return value && typeof value.then === 'function'; });
304
+ return Promise.allSettled(pendingOperations).then(function(){
305
+ quiesceChannels();
306
+ channelNames.forEach(function(name){ streamManager[name].clearCache(); });
307
+ controller.update(function(state){
308
+ state.cachedMarketDataByProvider = {};
309
+ state.cachedUserDataByProvider = {};
310
+ });
311
+ return Promise.all(keys.map(function(key){ return storage.removeItem(key); }));
312
+ }).then(function(){
313
+ return new Promise(function(resolve){ setTimeout(resolve, 250); });
314
+ }).then(function(){
315
+ return Promise.all(keys.map(function(key){ return storage.getItem(key); }));
316
+ }).then(function(values){
317
+ var remainingKeys = keys.filter(function(_key, index){ return values[index] !== null && values[index] !== undefined; });
318
+ if (remainingKeys.length > 0) {
319
+ throw new Error('Perps performance cache repopulated after clear: ' + remainingKeys.join(', '));
320
+ }
321
+ return JSON.stringify({
322
+ ok: true,
323
+ method: 'metro-cdp',
324
+ clearedStorageKeys: keys,
325
+ clearedControllerMarketEntries: marketEntries,
326
+ clearedControllerUserEntries: userEntries,
327
+ quiescedChannels: channelNames,
328
+ awaitedPendingOperations: pendingOperations.length,
329
+ storageVerifiedAbsent: true,
330
+ });
331
+ });
332
+ })()`;
333
+ }
334
+
335
+ async function clearPerformanceCaches(input) {
336
+ return evalAsync(input, clearPerformanceCachesExpression());
155
337
  }
156
338
 
157
339
  async function waitForPositionsAbsent(input, symbols, timeoutMs = 30000) {
@@ -846,6 +1028,7 @@ const DIRECT_ACTIONS = new Map([
846
1028
  ['metamask.perps.ensure_positions', ensurePositions],
847
1029
  ['metamask.perps.ensure_orders', ensureOrders],
848
1030
  ['metamask.perps.clear_performance_caches', clearPerformanceCaches],
1031
+ ['metamask.perps.measure_homepage_visible', measureHomepageVisible],
849
1032
  ['metamask.perps.start_state', startState],
850
1033
  ['metamask.perps.teardown_state', teardownState],
851
1034
  ]);
@@ -0,0 +1,58 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ import { runAdapter } from '../platform/bridge.mjs';
5
+ import { resolveMobileToolPath } from '../platform/tool-paths.mjs';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ async function prepareLocalSnapshotEndpoint(input) {
10
+ const port = Number(input.node?.port);
11
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
12
+ throw new Error(
13
+ 'metamask.perps.prepare_local_snapshot_endpoint requires an integer port between 1 and 65535.',
14
+ );
15
+ }
16
+
17
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
18
+ if (!serial) {
19
+ return {
20
+ action: input.action,
21
+ applied: false,
22
+ reason: 'not-required-for-ios-simulator',
23
+ port,
24
+ proofPath: 'local-snapshot-endpoint-platform-check',
25
+ };
26
+ }
27
+
28
+ const adb = resolveMobileToolPath('adb');
29
+ if (!adb) {
30
+ throw new Error(
31
+ 'metamask.perps.prepare_local_snapshot_endpoint could not resolve adb.',
32
+ );
33
+ }
34
+ const mapping = `tcp:${port}`;
35
+ await execFileAsync(adb, ['-s', serial, 'reverse', mapping, mapping], {
36
+ timeout: 10_000,
37
+ });
38
+ const { stdout } = await execFileAsync(adb, ['-s', serial, 'reverse', '--list'], {
39
+ timeout: 10_000,
40
+ });
41
+ const applied = stdout
42
+ .split(/\r?\n/u)
43
+ .some((line) => line.includes(`${mapping} ${mapping}`));
44
+ if (!applied) {
45
+ throw new Error(
46
+ `metamask.perps.prepare_local_snapshot_endpoint did not observe ${mapping} after applying it to ${serial}.`,
47
+ );
48
+ }
49
+ return {
50
+ action: input.action,
51
+ applied: true,
52
+ port,
53
+ redacted: true,
54
+ proofPath: 'adb-reverse-local-snapshot-endpoint',
55
+ };
56
+ }
57
+
58
+ runAdapter(prepareLocalSnapshotEndpoint);
@@ -23,6 +23,41 @@ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
23
23
 
24
24
  const execFileAsync = promisify(execFile);
25
25
 
26
+ let adapterActionActive = false;
27
+ let actionBridgeLockPath = null;
28
+
29
+ function resolveBridgeLockPath(input, env) {
30
+ const configuredRuntime = env.RECIPE_RUNTIME_DIR;
31
+ const runtime = configuredRuntime
32
+ ? (path.isAbsolute(configuredRuntime)
33
+ ? configuredRuntime
34
+ : path.resolve(input.context.projectRoot, configuredRuntime))
35
+ : path.join(input.context.projectRoot, 'temp', 'recipe', 'runtime');
36
+ return path.join(runtime, 'cdp-bridge.lock');
37
+ }
38
+
39
+ async function acquireActionBridgeLock(input, env) {
40
+ if (actionBridgeLockPath) return String(process.pid);
41
+ const lockPath = resolveBridgeLockPath(input, env);
42
+ await mkdir(path.dirname(lockPath), { recursive: true });
43
+ await writeFile(lockPath, String(process.pid));
44
+ actionBridgeLockPath = lockPath;
45
+ return String(process.pid);
46
+ }
47
+
48
+ async function releaseActionBridgeLock() {
49
+ const lockPath = actionBridgeLockPath;
50
+ actionBridgeLockPath = null;
51
+ if (!lockPath) return;
52
+ try {
53
+ if ((await readFile(lockPath, 'utf8')).trim() === String(process.pid)) {
54
+ await rm(lockPath, { force: true });
55
+ }
56
+ } catch {
57
+ // The bridge child or a newer owner may already have replaced the lock.
58
+ }
59
+ }
60
+
26
61
  // Recover the typed code a cdp-bridge child reported: the stderr marker is
27
62
  // authoritative (it carries the class the bridge classified at the source), the
28
63
  // coded exit status is the fallback for output-less failures, and a raw message
@@ -52,8 +87,14 @@ export async function writeOutput(input, output) {
52
87
 
53
88
  export async function runAdapter(callback) {
54
89
  const input = await loadInput();
55
- const output = await callback(input);
56
- await writeOutput(input, output);
90
+ adapterActionActive = true;
91
+ try {
92
+ const output = await callback(input);
93
+ await writeOutput(input, output);
94
+ } finally {
95
+ adapterActionActive = false;
96
+ await releaseActionBridgeLock();
97
+ }
57
98
  }
58
99
 
59
100
  function bridgeScript(input) {
@@ -127,7 +168,13 @@ export async function bridgeEnv(input) {
127
168
  // (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
128
169
  const serialStr = adbSerial != null ? String(adbSerial) : '';
129
170
  const deviceStr = androidDevice != null ? String(androidDevice) : '';
130
- if (serialStr && deviceStr === serialStr) {
171
+ if (
172
+ shouldResolveAndroidTargetModel(
173
+ serialStr,
174
+ deviceStr,
175
+ androidTargetDeviceName,
176
+ )
177
+ ) {
131
178
  const model = await resolveAndroidModel(serialStr);
132
179
  if (model) {
133
180
  env.ANDROID_TARGET_DEVICE_NAME = model;
@@ -150,6 +197,17 @@ export async function bridgeEnv(input) {
150
197
  return env;
151
198
  }
152
199
 
200
+ export function shouldResolveAndroidTargetModel(
201
+ adbSerial,
202
+ androidDevice,
203
+ androidTargetDeviceName,
204
+ ) {
205
+ const serial = String(adbSerial ?? '').trim();
206
+ const device = String(androidDevice ?? '').trim();
207
+ const targetName = String(androidTargetDeviceName ?? '').trim();
208
+ return Boolean(serial && !targetName && (!device || device === serial));
209
+ }
210
+
153
211
  function resolveMobileTarget(input) {
154
212
  const contextEnv = input.context?.env || {};
155
213
  const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
@@ -314,6 +372,9 @@ export async function bridgeCommand(input, args) {
314
372
  const script = bridgeScript(input);
315
373
  // bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
316
374
  const env = await bridgeEnv(input);
375
+ if (adapterActionActive) {
376
+ env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
377
+ }
317
378
  const result = await new Promise((resolve, reject) => {
318
379
  const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
319
380
  const child = spawn(process.execPath, [script, ...args], {
@@ -465,14 +526,21 @@ function valueContains(actual, expected) {
465
526
  return Object.is(actual, expected);
466
527
  }
467
528
 
468
- function routeMatches(route, expectedRoute, expectedParams) {
529
+ export function routeSatisfiesNavigationTarget(
530
+ route,
531
+ expectedRoute,
532
+ expectedParams,
533
+ ) {
469
534
  return routeName(route) === expectedRoute &&
470
535
  (expectedParams === undefined || valueContains(route?.params ?? {}, expectedParams));
471
536
  }
472
537
 
473
538
  function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expectedParams) {
474
- if (!previousRoute || !routeMatches(currentRoute, expectedRoute, expectedParams)) return false;
475
- if (!routeMatches(previousRoute, expectedRoute, expectedParams)) return true;
539
+ if (
540
+ !previousRoute ||
541
+ !routeSatisfiesNavigationTarget(currentRoute, expectedRoute, expectedParams)
542
+ ) return false;
543
+ if (!routeSatisfiesNavigationTarget(previousRoute, expectedRoute, expectedParams)) return true;
476
544
  return Boolean(
477
545
  previousRoute.key &&
478
546
  currentRoute?.key &&
@@ -502,6 +570,23 @@ export async function navigate(input, route, params = {}, expectedRoute) {
502
570
  if (!isTargetTransition(error)) throw error;
503
571
  }
504
572
 
573
+ if (
574
+ routeSatisfiesNavigationTarget(
575
+ previousRoute,
576
+ requestedRoute,
577
+ requestedParams,
578
+ )
579
+ ) {
580
+ return {
581
+ navigated: route,
582
+ params,
583
+ previousRoute,
584
+ currentRoute: previousRoute,
585
+ verifiedRoute: requestedRoute,
586
+ alreadyAtRoute: true,
587
+ };
588
+ }
589
+
505
590
  while (navigateAttempts < 2 || recoveryPending) {
506
591
  if (recoveryPending) {
507
592
  const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
@@ -537,7 +622,14 @@ export async function navigate(input, route, params = {}, expectedRoute) {
537
622
  },
538
623
  };
539
624
  }
540
- if (!previousRoute && routeMatches(currentRoute, requestedRoute, requestedParams)) {
625
+ if (
626
+ !previousRoute &&
627
+ routeSatisfiesNavigationTarget(
628
+ currentRoute,
629
+ requestedRoute,
630
+ requestedParams,
631
+ )
632
+ ) {
541
633
  break;
542
634
  }
543
635
  recoveryPending = false;
@@ -621,7 +713,9 @@ export async function waitForRoute(
621
713
  await waitForNextRouteProbe(deadline);
622
714
  continue;
623
715
  }
624
- if (routeMatches(lastRoute, expected, expectedParams)) return lastRoute;
716
+ if (
717
+ routeSatisfiesNavigationTarget(lastRoute, expected, expectedParams)
718
+ ) return lastRoute;
625
719
  pollCount += 1;
626
720
  await waitForNextRouteProbe(deadline);
627
721
  }
@@ -1,6 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { pathToFileURL } from 'node:url';
3
- import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
3
+ import {
4
+ bridgeCommand,
5
+ runAdapter,
6
+ selectBridgeStatusEntry,
7
+ } from '../platform/bridge.mjs';
4
8
  import { walletFixturePath } from '../../harness-exports.mjs';
5
9
 
6
10
  async function fixturePassword(projectRoot) {
@@ -39,7 +43,30 @@ function routeName(status, input) {
39
43
  const selectedStatus = selectBridgeStatusEntry;
40
44
 
41
45
  async function status(input) {
42
- return bridgeCommand(input, ['status']);
46
+ return bridgeCommand(input, ['status-selected']);
47
+ }
48
+
49
+ async function statusBeforeDeadline(input, deadline) {
50
+ const remainingMs = Math.max(1, deadline - Date.now());
51
+ const configuredMs = Number(
52
+ input.node?.bridge_timeout_ms ??
53
+ input.node?.cdp_timeout_ms ??
54
+ process.env.CDP_TIMEOUT ??
55
+ 30000,
56
+ );
57
+ const probeTimeoutMs = Math.min(
58
+ 5000,
59
+ remainingMs,
60
+ Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000,
61
+ );
62
+ return status({
63
+ ...input,
64
+ node: {
65
+ ...input.node,
66
+ bridge_timeout_ms: probeTimeoutMs,
67
+ cdp_timeout_ms: probeTimeoutMs,
68
+ },
69
+ });
43
70
  }
44
71
 
45
72
  async function waitForTargetStatus(input, timeoutMs = 20000) {
@@ -48,7 +75,7 @@ async function waitForTargetStatus(input, timeoutMs = 20000) {
48
75
  let lastError = null;
49
76
  while (Date.now() < deadline) {
50
77
  try {
51
- last = await status(input);
78
+ last = await statusBeforeDeadline(input, deadline);
52
79
  const selected = selectedStatus(last, input);
53
80
  if (selected?.agenticPresent === true) return last;
54
81
  } catch (error) {
@@ -69,7 +96,7 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
69
96
  if (Date.now() >= deadline) break;
70
97
  await new Promise((resolve) => setTimeout(resolve, 250));
71
98
  try {
72
- last = await status(input);
99
+ last = await statusBeforeDeadline(input, deadline);
73
100
  } catch {
74
101
  // A rotating Hermes target during startup is transient. The target timeout
75
102
  // still bounds this loop and the final error reports the last wallet state.
@@ -80,13 +107,13 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
80
107
  );
81
108
  }
82
109
 
83
- async function waitForUnlocked(input, timeoutMs = 15000) {
110
+ async function waitForUnlocked(input, timeoutMs = 30000) {
84
111
  const deadline = Date.now() + timeoutMs;
85
112
  let last = null;
86
113
  let lastError = null;
87
114
  while (Date.now() < deadline) {
88
115
  try {
89
- last = await status(input);
116
+ last = await statusBeforeDeadline(input, deadline);
90
117
  if (selectedAccount(last, input) && routeName(last, input) !== 'Login') return last;
91
118
  } catch (error) {
92
119
  lastError = error;
@@ -108,7 +135,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
108
135
  while (Date.now() < deadline) {
109
136
  await new Promise((resolve) => setTimeout(resolve, 250));
110
137
  try {
111
- last = await status(input);
138
+ last = await statusBeforeDeadline(input, deadline);
112
139
  if (!isUnlockedStatus(last, input)) {
113
140
  if (routeName(last, input) === 'Login') return null;
114
141
  transientDrops += 1;
@@ -152,7 +179,7 @@ export async function ensureUnlocked(input) {
152
179
  const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
153
180
  try {
154
181
  const result = await bridgeCommand(input, ['unlock', String(password)]);
155
- const after = await waitForUnlocked(input, Number(input.node?.unlock_timeout_ms ?? 15000));
182
+ const after = await waitForUnlocked(input, Number(input.node?.unlock_timeout_ms ?? 30000));
156
183
  const stableAfter = await waitForStableUnlocked(input, after, Number(input.node?.stable_unlocked_ms ?? 750)) ?? after;
157
184
  return {
158
185
  action: input.action,