@deeeed/metamask-harness 0.44.2 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,4 @@
1
1
  import { runAdapter } from '../platform/bridge.mjs';
2
- import { observeNativeUi } from '../platform/observe-ui.mjs';
3
- import {
4
- normalizeVisiblePerpsState,
5
- resolveVisibleStateOptions,
6
- } from '../../shared/perps/visible-state.mjs';
2
+ import { readVisiblePerpsState } from './read-visible-state-loop.mjs';
7
3
 
8
- runAdapter(async (input) => {
9
- const options = resolveVisibleStateOptions(input.node);
10
- const observed = await observeNativeUi(
11
- { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
- input.context,
13
- );
14
- const screen = observed.observations?.['ui.screen'];
15
- const visible = observed.observations?.['ui.visible'];
16
- if (!screen || !visible) {
17
- const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
- throw new Error(`Mobile visible Perps state is unavailable${warning ? `: ${warning}` : '.'}`);
19
- }
20
- return {
21
- action: input.action,
22
- ...normalizeVisiblePerpsState({
23
- route: screen.route,
24
- title: screen.title,
25
- items: visible.items,
26
- offscreenItems: visible.hidden_or_offscreen,
27
- truncated: visible.truncated,
28
- }, options, 'mobile'),
29
- proofPath: 'native-accessibility',
30
- };
31
- });
4
+ runAdapter(readVisiblePerpsState);
@@ -227,7 +227,7 @@ export function shouldResolveAndroidTargetModel(
227
227
  return Boolean(serial && !targetName && (!device || device === serial));
228
228
  }
229
229
 
230
- function resolveMobileTarget(input) {
230
+ export function resolveMobileTarget(input) {
231
231
  const contextEnv = input.context?.env || {};
232
232
  const explicitSimulatorUdid =
233
233
  input.node?.sim_udid ?? contextEnv.SIM_UDID ?? process.env.SIM_UDID;
@@ -134,7 +134,7 @@ async function readAndroidHierarchy(node, env) {
134
134
  );
135
135
  const hierarchy = parseAndroidHierarchy(stdout);
136
136
  if (hierarchy.nodeCount === 0) {
137
- throw new Error('adb returned an empty UIAutomator hierarchy.');
137
+ return readAndroidAgentDeviceHierarchy(serial, env);
138
138
  }
139
139
  return normalizedHierarchy(
140
140
  'adb-uiautomator',
@@ -364,7 +364,7 @@ function flattenIosHierarchy(value) {
364
364
  screenName ??= label;
365
365
  screenBounds ??= bounds;
366
366
  }
367
- if (isActionableRole(role)) {
367
+ if (isActionableRole(role) || testId) {
368
368
  const normalized = compactItem({
369
369
  role,
370
370
  label,
@@ -4,6 +4,10 @@ import {
4
4
  nativeAgentDeviceStateDir,
5
5
  nativeSessionName,
6
6
  } from '../platform/native-session-name.mjs';
7
+ import { bridgeCommand } from '../platform/bridge.mjs';
8
+ import {
9
+ observeNativeUi,
10
+ } from '../platform/observe-ui.mjs';
7
11
 
8
12
  const IDS = {
9
13
  wallet: ['wallet-safe-area', 'homepage-container'],
@@ -151,6 +155,215 @@ export async function navigateOpaqueMobile(input, destination) {
151
155
  }
152
156
  }
153
157
 
158
+ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
159
+ if (!['lite', 'pro'].includes(perpsMode)) {
160
+ throw new Error('Opaque Mobile Perps mode must be lite or pro.');
161
+ }
162
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
163
+ const device = String(
164
+ env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? env.ANDROID_DEVICE ?? '',
165
+ ).trim();
166
+ if (!device) {
167
+ throw new Error(
168
+ 'Android Perps mode requires one explicit device serial.',
169
+ );
170
+ }
171
+ const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
172
+ const session = nativeSessionName(env, device, app);
173
+ const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
174
+ const client = createAgentDeviceClient({
175
+ session,
176
+ stateDir,
177
+ lockPolicy: 'reject',
178
+ lockPlatform: 'android',
179
+ });
180
+ const selection = { platform: 'android', target: 'mobile', serial: device, session };
181
+ const transport = createAgentDeviceUiTransport({
182
+ platform: 'android',
183
+ device,
184
+ app,
185
+ session,
186
+ stateDir,
187
+ client,
188
+ });
189
+ const context = {
190
+ nodeId: String(input.context?.nodeId ?? 'navigate'),
191
+ projectRoot: input.context.projectRoot,
192
+ artifactsDir: input.context.artifactsDir,
193
+ env,
194
+ };
195
+ const press = (testId) => transport.execute('ui.press', {
196
+ test_id: testId,
197
+ timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
198
+ settle: false,
199
+ }, context);
200
+ const snapshot = () => client.capture.snapshot({
201
+ platform: 'android',
202
+ target: 'mobile',
203
+ serial: device,
204
+ session,
205
+ app,
206
+ interactiveOnly: false,
207
+ forceFull: true,
208
+ });
209
+ const identifiers = async () => new Set(
210
+ (await snapshot()).nodes
211
+ .filter((node) => node.visibleToUser !== false)
212
+ .map((node) => String(node.identifier ?? '')),
213
+ );
214
+
215
+ try {
216
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
217
+ const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
218
+ const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
219
+ const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
220
+ let expectedStreak = 0;
221
+ let currentStreak = 0;
222
+ let optionStreak = 0;
223
+ let currentAttempts = 0;
224
+ let optionAttempts = 0;
225
+ let lastObservationError;
226
+ while (Date.now() <= deadline) {
227
+ let ids;
228
+ try {
229
+ ids = await identifiers();
230
+ lastObservationError = undefined;
231
+ } catch (error) {
232
+ lastObservationError = String(error?.message ?? error);
233
+ expectedStreak = 0;
234
+ currentStreak = 0;
235
+ optionStreak = 0;
236
+ await new Promise((resolve) => setTimeout(resolve, 250));
237
+ continue;
238
+ }
239
+ if (ids.has(expectedToggle)) {
240
+ expectedStreak += 1;
241
+ if (expectedStreak >= 2) {
242
+ return {
243
+ mode: perpsMode,
244
+ observedScreen: expectedToggle,
245
+ provider: 'android-native-accessibility',
246
+ proofPath: 'visible-native-navigation',
247
+ };
248
+ }
249
+ await new Promise((resolve) => setTimeout(resolve, 250));
250
+ continue;
251
+ }
252
+ expectedStreak = 0;
253
+ if (ids.has(option)) {
254
+ optionStreak += 1;
255
+ if (optionAttempts === 0 || (optionStreak >= 2 && optionAttempts < 2)) {
256
+ optionAttempts += 1;
257
+ optionStreak = 0;
258
+ await pressCurrent(press, option);
259
+ }
260
+ await new Promise((resolve) => setTimeout(resolve, 250));
261
+ continue;
262
+ }
263
+ optionStreak = 0;
264
+ if (
265
+ (ids.has(IDS.market) || ids.has(IDS.proMarket)) &&
266
+ ids.has(currentToggle)
267
+ ) {
268
+ currentStreak += 1;
269
+ if (currentAttempts === 0 || (currentStreak >= 2 && currentAttempts < 2)) {
270
+ currentAttempts += 1;
271
+ currentStreak = 0;
272
+ await pressCurrent(press, currentToggle);
273
+ }
274
+ await new Promise((resolve) => setTimeout(resolve, 250));
275
+ continue;
276
+ }
277
+ currentStreak = 0;
278
+ await new Promise((resolve) => setTimeout(resolve, 250));
279
+ }
280
+ throw new Error(
281
+ `Android navigation did not visibly switch Perps to ${perpsMode} mode` +
282
+ `${lastObservationError ? `: ${lastObservationError}` : '.'}`,
283
+ );
284
+ } finally {
285
+ await closeNativeSession(client, selection);
286
+ }
287
+ }
288
+
289
+ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
290
+ if (!['lite', 'pro'].includes(perpsMode)) {
291
+ throw new Error('iOS Perps mode must be lite or pro.');
292
+ }
293
+ const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
294
+ const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
295
+ const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
296
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
297
+ let lastObserved = [];
298
+ let lastWarning;
299
+ let expectedStreak = 0;
300
+ let currentStreak = 0;
301
+ let optionStreak = 0;
302
+ let currentAttempts = 0;
303
+ let optionAttempts = 0;
304
+ while (Date.now() <= deadline) {
305
+ let observed;
306
+ try {
307
+ observed = await observeNativeUi(
308
+ { refs: ['ui.visible'], node: input.node },
309
+ input.context,
310
+ );
311
+ } catch (error) {
312
+ lastWarning = String(error?.message ?? error);
313
+ expectedStreak = 0;
314
+ currentStreak = 0;
315
+ optionStreak = 0;
316
+ await new Promise((resolve) => setTimeout(resolve, 250));
317
+ continue;
318
+ }
319
+ lastWarning = observed.warnings?.map((warning) => warning.message).filter(Boolean).join('; ');
320
+ const items = observed.observations?.['ui.visible']?.items ?? [];
321
+ const ids = new Set(items.map((item) => String(item.test_id ?? '')));
322
+ lastObserved = [...ids].filter(Boolean).slice(0, 20);
323
+ if (ids.has(expectedToggle)) {
324
+ expectedStreak += 1;
325
+ if (expectedStreak >= 2) {
326
+ return {
327
+ mode: perpsMode,
328
+ observedScreen: expectedToggle,
329
+ provider: 'ios-native-accessibility',
330
+ proofPath: 'visible-native-navigation',
331
+ };
332
+ }
333
+ await new Promise((resolve) => setTimeout(resolve, 250));
334
+ continue;
335
+ }
336
+ expectedStreak = 0;
337
+ if (ids.has(option)) {
338
+ optionStreak += 1;
339
+ if (optionAttempts === 0 || (optionStreak >= 2 && optionAttempts < 2)) {
340
+ optionAttempts += 1;
341
+ optionStreak = 0;
342
+ await bridgeCommand(input, ['press-test-id', option]);
343
+ }
344
+ await new Promise((resolve) => setTimeout(resolve, 250));
345
+ continue;
346
+ }
347
+ optionStreak = 0;
348
+ if (ids.has(currentToggle)) {
349
+ currentStreak += 1;
350
+ if (currentAttempts === 0 || (currentStreak >= 2 && currentAttempts < 2)) {
351
+ currentAttempts += 1;
352
+ currentStreak = 0;
353
+ await bridgeCommand(input, ['press-test-id', currentToggle]);
354
+ }
355
+ await new Promise((resolve) => setTimeout(resolve, 250));
356
+ continue;
357
+ }
358
+ currentStreak = 0;
359
+ await new Promise((resolve) => setTimeout(resolve, 250));
360
+ }
361
+ throw new Error(
362
+ `iOS navigation did not visibly switch Perps to ${perpsMode} mode. ` +
363
+ `Observed: ${lastObserved.join(', ') || 'none'}${lastWarning ? `. ${lastWarning}` : ''}`,
364
+ );
365
+ }
366
+
154
367
  async function navigateSwap({ identifiers, press, wait, back }) {
155
368
  let ids = await identifiers();
156
369
  if (ids.has(IDS.swapSourceInput)) return IDS.swapSourceInput;
@@ -1,5 +1,13 @@
1
- import { navigate, runAdapter } from '../platform/bridge.mjs';
2
- import { navigateOpaqueMobile } from './native-navigation.mjs';
1
+ import {
2
+ navigate,
3
+ resolveMobileTarget,
4
+ runAdapter,
5
+ } from '../platform/bridge.mjs';
6
+ import {
7
+ ensureVisibleAndroidPerpsMode,
8
+ ensureVisibleIosPerpsMode,
9
+ navigateOpaqueMobile,
10
+ } from './native-navigation.mjs';
3
11
 
4
12
  const PAGE_ROUTES = {
5
13
  home: { route: 'WalletView', params: {} },
@@ -44,8 +52,33 @@ runAdapter(async (input) => {
44
52
  ) {
45
53
  return navigateOpaqueMobile(input, alias);
46
54
  }
47
- const navigation = await navigate(input, alias.route, alias.params);
48
- return { action: input.action, ...alias, navigation, proofPath: 'agentic-navigation' };
55
+ const navigationParams = alias.page === 'perps-market'
56
+ ? { market: alias.params.market }
57
+ : alias.params;
58
+ const navigation = await navigate(input, alias.route, navigationParams);
59
+ const requestedPlatform = resolveMobileTarget(input).requestedPlatform;
60
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
61
+ const platform =
62
+ requestedPlatform ||
63
+ (env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_DEVICE
64
+ ? 'android'
65
+ : 'ios');
66
+ const visibleMode =
67
+ alias.page === 'perps-market' && text(input.node?.mode)
68
+ ? platform === 'android'
69
+ ? await ensureVisibleAndroidPerpsMode(input, alias.params.mode)
70
+ : await ensureVisibleIosPerpsMode(input, alias.params.mode)
71
+ : undefined;
72
+ return {
73
+ action: input.action,
74
+ ...alias,
75
+ params: navigationParams,
76
+ navigation,
77
+ ...(visibleMode ? { visibleMode } : {}),
78
+ proofPath: visibleMode
79
+ ? 'agentic-navigation+visible-native-navigation'
80
+ : 'agentic-navigation',
81
+ };
49
82
  }
50
83
 
51
84
  const route = input.node?.route ?? input.node?.screen;
@@ -56,8 +56,7 @@ async function statusBeforeDeadline(input, deadline) {
56
56
  30000,
57
57
  );
58
58
  const probeTimeoutMs = Math.min(
59
- 15000,
60
- Math.max(5000, Math.floor(remainingMs / 3)),
59
+ Math.max(750, Math.floor(remainingMs / 3)),
61
60
  remainingMs,
62
61
  Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000,
63
62
  );
@@ -141,32 +140,41 @@ function isUnlockedStatus(status, input) {
141
140
  );
142
141
  }
143
142
 
144
- async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
143
+ async function waitForStableUnlocked(
144
+ input,
145
+ initialStatus,
146
+ stableMs = 750,
147
+ recoveryTimeoutMs = 30000,
148
+ ) {
145
149
  if (!isUnlockedStatus(initialStatus, input)) return null;
146
- const deadline = Date.now() + stableMs;
150
+ if (stableMs <= 0) return initialStatus;
151
+ const deadline =
152
+ Date.now() + Math.max(stableMs + 250, recoveryTimeoutMs);
153
+ let stableSince = Date.now();
147
154
  let last = initialStatus;
148
- let transientDrops = 0;
149
155
  while (Date.now() < deadline) {
156
+ if (stableSince !== null && Date.now() - stableSince >= stableMs) {
157
+ return last;
158
+ }
150
159
  await new Promise((resolve) => setTimeout(resolve, 250));
151
160
  try {
152
161
  last = await statusBeforeDeadline(input, deadline);
153
162
  if (!isUnlockedStatus(last, input)) {
154
163
  if (routeName(last, input) === 'Login') return null;
155
- transientDrops += 1;
156
- if (transientDrops > 2) return null;
164
+ stableSince = null;
157
165
  continue;
158
166
  }
159
- transientDrops = 0;
167
+ stableSince ??= Date.now();
160
168
  } catch {
161
- // Recovery is correct: a bridge error during the stability window is the
162
- // same signal as an unlocked-state drop count it toward the transient
163
- // threshold; exceeding it returns null so the caller runs the real
164
- // password unlock instead of reporting a false alreadyUnlocked.
165
- transientDrops += 1;
166
- if (transientDrops > 2) return null;
169
+ // The Hermes page can rotate after Login -> Home. Keep waiting for the
170
+ // same pinned runtime to become observable again; do not restart Metro or
171
+ // the app, and restart the stability window when it returns.
172
+ stableSince = null;
167
173
  }
168
174
  }
169
- return isUnlockedStatus(last, input) ? last : null;
175
+ return stableSince !== null && Date.now() - stableSince >= stableMs
176
+ ? last
177
+ : null;
170
178
  }
171
179
 
172
180
  export async function ensureUnlocked(input) {
@@ -194,7 +202,12 @@ export async function ensureUnlocked(input) {
194
202
  targetStatus,
195
203
  Math.max(0, targetDeadline - Date.now()),
196
204
  );
197
- const stableBefore = await waitForStableUnlocked(input, before, Number(input.node?.stable_unlocked_ms ?? 750));
205
+ const stableBefore = await waitForStableUnlocked(
206
+ input,
207
+ before,
208
+ Number(input.node?.stable_unlocked_ms ?? 750),
209
+ Math.max(0, targetDeadline - Date.now()),
210
+ );
198
211
  if (stableBefore) {
199
212
  return {
200
213
  action: input.action,
@@ -251,10 +264,12 @@ export async function ensureUnlocked(input) {
251
264
  }
252
265
  const remainingUnlockMs = Math.max(1, unlockDeadline - Date.now());
253
266
  const after = await waitForUnlocked(input, remainingUnlockMs);
267
+ const remainingStabilityMs = Math.max(1, unlockDeadline - Date.now());
254
268
  const stableAfter = await waitForStableUnlocked(
255
269
  input,
256
270
  after,
257
271
  Number(input.node?.stable_unlocked_ms ?? 750),
272
+ remainingStabilityMs,
258
273
  );
259
274
  if (!stableAfter) {
260
275
  throw new Error('Mobile wallet did not remain unlocked for the required stability window.');
@@ -2162,6 +2162,11 @@
2162
2162
  "type": "integer",
2163
2163
  "minimum": 1,
2164
2164
  "default": 100
2165
+ },
2166
+ "timeout_ms": {
2167
+ "type": "integer",
2168
+ "minimum": 1000,
2169
+ "default": 30000
2165
2170
  }
2166
2171
  },
2167
2172
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.44.2",
3
+ "version": "0.45.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -24,7 +24,7 @@
24
24
  "@farmslot/expo-recipe": "0.8.1",
25
25
  "@farmslot/handoff": "^0.3.1",
26
26
  "@farmslot/protocol": "0.21.0",
27
- "@farmslot/recipe-harness": "0.15.0",
27
+ "@farmslot/recipe-harness": "0.15.1",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",