@deeeed/metamask-harness 0.44.3 → 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.
- package/CHANGELOG.md +15 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +21 -3
- package/dist/adapters/mobile/android-gfxinfo-performance.js +368 -0
- package/dist/adapters/mobile/frame-metrics.js +88 -15
- package/dist/adapters/mobile/performance-observer.js +4 -0
- package/dist/adapters/performance/cdp-trace.js +55 -9
- package/dist/performance-observation.js +145 -32
- package/docs/PERFORMANCE-CAPTURE.md +35 -6
- package/library/actions/mobile/perps/read-visible-state-loop.mjs +73 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +2 -29
- package/library/actions/mobile/platform/bridge.mjs +1 -1
- package/library/actions/mobile/platform/observe-ui.mjs +2 -2
- package/library/actions/mobile/ui/native-navigation.mjs +213 -0
- package/library/actions/mobile/ui/navigate.mjs +37 -4
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +31 -16
- package/library/manifests/mobile.action-manifest.json +5 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 {
|
|
2
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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(
|
|
143
|
+
async function waitForStableUnlocked(
|
|
144
|
+
input,
|
|
145
|
+
initialStatus,
|
|
146
|
+
stableMs = 750,
|
|
147
|
+
recoveryTimeoutMs = 30000,
|
|
148
|
+
) {
|
|
145
149
|
if (!isUnlockedStatus(initialStatus, input)) return null;
|
|
146
|
-
|
|
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
|
-
|
|
156
|
-
if (transientDrops > 2) return null;
|
|
164
|
+
stableSince = null;
|
|
157
165
|
continue;
|
|
158
166
|
}
|
|
159
|
-
|
|
167
|
+
stableSince ??= Date.now();
|
|
160
168
|
} catch {
|
|
161
|
-
//
|
|
162
|
-
// same
|
|
163
|
-
//
|
|
164
|
-
|
|
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
|
|
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(
|
|
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.');
|