@deeeed/metamask-harness 0.34.1 → 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.
@@ -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);
@@ -526,14 +526,21 @@ function valueContains(actual, expected) {
526
526
  return Object.is(actual, expected);
527
527
  }
528
528
 
529
- function routeMatches(route, expectedRoute, expectedParams) {
529
+ export function routeSatisfiesNavigationTarget(
530
+ route,
531
+ expectedRoute,
532
+ expectedParams,
533
+ ) {
530
534
  return routeName(route) === expectedRoute &&
531
535
  (expectedParams === undefined || valueContains(route?.params ?? {}, expectedParams));
532
536
  }
533
537
 
534
538
  function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expectedParams) {
535
- if (!previousRoute || !routeMatches(currentRoute, expectedRoute, expectedParams)) return false;
536
- 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;
537
544
  return Boolean(
538
545
  previousRoute.key &&
539
546
  currentRoute?.key &&
@@ -563,6 +570,23 @@ export async function navigate(input, route, params = {}, expectedRoute) {
563
570
  if (!isTargetTransition(error)) throw error;
564
571
  }
565
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
+
566
590
  while (navigateAttempts < 2 || recoveryPending) {
567
591
  if (recoveryPending) {
568
592
  const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
@@ -598,7 +622,14 @@ export async function navigate(input, route, params = {}, expectedRoute) {
598
622
  },
599
623
  };
600
624
  }
601
- if (!previousRoute && routeMatches(currentRoute, requestedRoute, requestedParams)) {
625
+ if (
626
+ !previousRoute &&
627
+ routeSatisfiesNavigationTarget(
628
+ currentRoute,
629
+ requestedRoute,
630
+ requestedParams,
631
+ )
632
+ ) {
602
633
  break;
603
634
  }
604
635
  recoveryPending = false;
@@ -682,7 +713,9 @@ export async function waitForRoute(
682
713
  await waitForNextRouteProbe(deadline);
683
714
  continue;
684
715
  }
685
- if (routeMatches(lastRoute, expected, expectedParams)) return lastRoute;
716
+ if (
717
+ routeSatisfiesNavigationTarget(lastRoute, expected, expectedParams)
718
+ ) return lastRoute;
686
719
  pollCount += 1;
687
720
  await waitForNextRouteProbe(deadline);
688
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) {