@deeeed/metamask-harness 0.44.3 → 0.45.1

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,6 +1,6 @@
1
1
  # UI smoothness capture
2
2
 
3
- `app.performance_capture` attributes CDP trace samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
3
+ `app.performance_capture` attributes native frame samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
4
4
 
5
5
  ```json
6
6
  {
@@ -13,21 +13,50 @@
13
13
  }
14
14
  ```
15
15
 
16
- End the same ID with `phase: "end"`. The action writes bounded JSON and a self-contained HTML report. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
16
+ End the same ID with `phase: "end"`. The action writes a machine-readable schema-v2 summary, a self-contained HTML report, and a bounded gzip-compressed Chrome Trace Event file named `*-trace.json.gz`. Chrome DevTools and Perfetto can consume the trace format. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
17
+
18
+ ## How it works
19
+
20
+ 1. `app.performance_capture` with `phase: "start"` selects the platform source and starts a bounded capture window.
21
+ 2. The recipe runner records each node's start and end on the host clock while the interaction runs.
22
+ 3. `phase: "end"` maps native frame timestamps onto those node intervals and closes the source before building artifacts.
23
+ 4. The harness writes summary JSON for tools, self-contained HTML for people, and a gzip-compressed Chrome trace for DevTools or Perfetto.
24
+ 5. `app.performance_assert` checks source status, frame count, and required node attribution. Missing coverage fails or remains `N/A`; it never becomes zero.
25
+
26
+ ## Host prerequisites
27
+
28
+ Run `mm-harness doctor --json` from the target checkout before performance work on a new machine.
29
+
30
+ | Target | Required host tools | Notes |
31
+ | --- | --- | --- |
32
+ | Android | `adb` from Android Platform Tools | `gfxinfo framestats` is built into Android; there is no separate `gfxinfo` package to install. |
33
+ | iOS Simulator | Xcode command-line tools (`xcrun`) | Native frames require a debug client with `unstable_frameRecordingEnabled: true`. Visible UI assertions and `ui.navigate page=perps-market mode=...` also require `idb`. |
34
+ | Extension | Chrome or Chromium with an isolated CDP profile | The harness uses Chromium's Tracing domain. |
35
+ | Trace viewing | Chrome DevTools or Perfetto | Optional for recipe execution; required only to inspect the raw `*-trace.json.gz` file visually. |
36
+
37
+ `Flashlight`, Instruments, and an in-app FPS overlay are not harness dependencies. Keep the recipe HUD visible; it is separate from the performance source.
17
38
 
18
39
  ## Sources and status
19
40
 
20
- - Mobile uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. iOS frame timings come from its built-in `CADisplayLink` observer. Android frame timings come from `Window.FrameMetrics`. The app build must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is partial or unavailable.
41
+ - Android polls `adb shell dumpsys gfxinfo <package> framestats` from the host. It maps Android uptime to recipe timestamps, merges frame snapshots, and measures `FrameCompleted - IntendedVsync` against the device's refresh-rate budget. It does not need a Mobile patch.
42
+ - iOS uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. Frame timings come from its native `CADisplayLink` observer. The debug app must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is unavailable. Instruments `Animation Hitches` and `Core Animation FPS` do not support iOS Simulator.
43
+ - React Native rejects iOS Tracing after more than one RN host has been registered during the app process lifetime. Its counter is cumulative: removing the old host does not decrement it. Unlocking does not create or remove a host; the MetaMask unlock path only unlocks the wallet and navigates. The observed second host came from reopening an already-running Expo dev-client process against Metro.
44
+ - Establish the iOS measurement cohort explicitly: run `app.lifecycle` with `command: "restart"` outside every capture window, unlock the keyring after the process restart, prove the wallet reached Home, then start `app.performance_capture`. Unlocking does not register another host; restart → unlock → trace passed in one process. This terminates only the app process. It must not restart Metro or the simulator, and capture failure must never trigger it as automatic recovery.
21
45
  - Extension uses Chromium's implementation of the same CDP Tracing domain. Frame and JavaScript data count only after the harness proves they belong to the MetaMask renderer. Unresolved browser-wide events produce partial or unavailable coverage.
22
- - The harness records a CDP clock marker and maps trace timestamps onto recipe-node timestamps. It waits for `Tracing.tracingComplete` before writing evidence.
46
+ - CDP sources record a clock marker and map trace timestamps onto recipe-node timestamps. They wait for `Tracing.tracingComplete` before writing evidence. Android uses the uptime value emitted by `gfxinfo`; the report exposes the measured clock-mapping uncertainty because it bounds node attribution.
23
47
  - The JSON report includes bounded trace evidence counts for `BeginFrame`, `DrawFrame`, `RunTask`, and `ProfileChunk`, plus data-loss and retention-overflow flags.
24
- - UI frames and JavaScript work remain separate sources. `RunTask` events produce JavaScript task summaries. Sampling `ProfileChunk` events prove profiler data exists but are not converted to task timing or FPS. JavaScript work is never labelled as UI or JS FPS.
48
+ - UI frames and JavaScript work remain separate sources. Chromium `RunTask` events produce JavaScript task summaries on Extension. React Native Mobile does not emit `RunTask`, so Mobile reports those fields as not applicable and judges coverage from native UI frames. Sampling `ProfileChunk` events prove Hermes profiler data exists but are not converted to task timing or FPS.
49
+ - Active UI FPS uses consecutive native vsync intervals only while frames are being produced. Fewer than five usable intervals or an unclassified long cadence gap reports `N/A`. Sampling jitter can never raise active FPS above the detected refresh rate.
50
+ - The trace cadence determines the refresh rate and fallback per-frame budget. Android over-budget percentage uses `FrameDeadline` when present; frame percentiles use `FrameCompleted - IntendedVsync`. iOS percentiles describe continuous `CADisplayLink` cadence rather than render work duration. An unclassified long gap forces partial coverage and `N/A` FPS. It stays visible as a gap and can be the worst observed sample, but it is not classified as an over-budget frame.
25
51
  - `complete`, `partial`, and `unavailable` describe source coverage. Missing data is never converted to zero.
52
+ - Raw trace timestamps use host epoch microseconds. Standard complete events on the `MetaMask Harness / Recipe nodes` track preserve the node boundaries used by the HTML and JSON reports.
26
53
 
27
54
  `app.performance_assert.minimum_frame_count` applies only to native UI or Chromium renderer frames. JavaScript task count cannot satisfy it.
28
55
 
56
+ Node duration includes automation, native state checks, and wait time inside the node. It is not screen time-to-content. Use product lifecycle milestones for section visibility and data readiness.
57
+
29
58
  Capture is explicit only. Use the action for comparisons on the same runtime and build. Automatic capture remains disabled until its overhead passes a matched benchmark.
30
59
 
31
60
  ## Sentry promotion boundary
32
61
 
33
- This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, jank percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
62
+ This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, over-budget percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
package/docs/RECIPES.md CHANGED
@@ -211,6 +211,17 @@ Rules:
211
211
 
212
212
  - Validate with `--plan` before side effects.
213
213
  - Use real product paths; never mutate hidden state to fabricate proof.
214
+ - Never repair the product while proving it. A normal action must not restart,
215
+ reload, foreground, reconnect, clear caches, reset state, or rerun the
216
+ journey after failure. Observe the product's natural result and fail with the
217
+ last observed state when it does not arrive.
218
+ - Put intended setup and lifecycle transitions in their own recipe nodes. Use
219
+ an explicit lifecycle node when restart, reload, reconnect, background, or
220
+ foreground behavior is the claim. A persistence proof must show the
221
+ transition as a node between the immediate and persisted assertions.
222
+ - Keep setup outside the proof window. Setup may converge fixture-backed state
223
+ when the node names the mutation and the trace records it, but setup evidence
224
+ cannot prove the behavior under test.
214
225
  - Prove preparation with an independent read/assert or visible UI postcondition.
215
226
  - Keep ticket-specific claims in task-local recipes.
216
227
  - Compose, repair, or parameterize repeated behavior instead of multiplying names.
@@ -248,7 +259,8 @@ Keep responsibilities narrow:
248
259
  - **Recipes** compose actions and own journey order, safe defaults, invariants,
249
260
  and proof.
250
261
  - **Actions** translate one stable operation to existing UI, CDP, or controller
251
- capabilities; they do not reimplement product business logic.
262
+ capabilities; they do not reimplement product business logic or hide
263
+ lifecycle recovery.
252
264
  - **Product/controllers** remain the source of truth for state transitions,
253
265
  validation, transactions, and domain behavior.
254
266
 
@@ -92,12 +92,8 @@ async function waitForExactPrices(page, identity, facts, timeoutMs) {
92
92
  throw new Error(`exact TP/SL postcondition was not visible: ${JSON.stringify(after)}.`);
93
93
  }
94
94
 
95
- async function observePersistedPrices(page, identity, facts, timeoutMs) {
95
+ async function observeUpdatedPrices(page, identity, facts, timeoutMs) {
96
96
  const immediate = await waitForExactPrices(page, identity, facts, timeoutMs);
97
- await page.session.call('Page.reload', { ignoreCache: true });
98
- await new Promise((resolve) => setTimeout(resolve, 500));
99
- await page.waitForSelector(dataTestId('perps-position-size-value'), { timeoutMs: 20_000 });
100
- const persisted = await waitForExactPrices(page, identity, facts, timeoutMs);
101
97
  return {
102
98
  success: true,
103
99
  positionBefore: facts.positionBefore,
@@ -107,7 +103,6 @@ async function observePersistedPrices(page, identity, facts, timeoutMs) {
107
103
  stopLossPrice: facts.stopLossPrice,
108
104
  },
109
105
  immediate,
110
- persistedAfterReload: persisted,
111
106
  };
112
107
  }
113
108
 
@@ -191,7 +186,7 @@ export async function updatePositionTpsl(input) {
191
186
  input,
192
187
  identity,
193
188
  dispatch: async () => { throw new Error('resumed TP/SL mutation must not dispatch'); },
194
- observe: async ({ receipt }) => observePersistedPrices(
189
+ observe: async ({ receipt }) => observeUpdatedPrices(
195
190
  page,
196
191
  identity,
197
192
  receipt.facts,
@@ -264,7 +259,7 @@ export async function updatePositionTpsl(input) {
264
259
  button: 'left', buttons: 0, clickCount: 1,
265
260
  });
266
261
  },
267
- observe: async ({ receipt }) => observePersistedPrices(
262
+ observe: async ({ receipt }) => observeUpdatedPrices(
268
263
  page,
269
264
  identity,
270
265
  receipt.facts,
@@ -822,16 +822,12 @@ export async function placeOrder(input) {
822
822
  if (result?.success === false || result == null) {
823
823
  throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
824
824
  }
825
- const refresh = await evalAsync(
826
- input,
827
- 'globalThis.__AGENTIC__ && globalThis.__AGENTIC__.refreshPerpsStreams ? globalThis.__AGENTIC__.refreshPerpsStreams().then(function(r){return JSON.stringify(r)}) : Promise.resolve(JSON.stringify({ ok: false, reason: "refreshPerpsStreams unavailable" }))',
828
- );
829
825
  if (orderType === 'limit') {
830
826
  await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
831
827
  } else {
832
828
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
833
829
  }
834
- return { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, refresh, proofPath: 'mobile-perps-controller-place-order' };
830
+ return { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, proofPath: 'mobile-perps-controller-place-order' };
835
831
  }
836
832
 
837
833
  export async function ensurePositions(input) {
@@ -0,0 +1,73 @@
1
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
2
+ import {
3
+ normalizeVisiblePerpsState,
4
+ resolveVisibleStateOptions,
5
+ } from '../../shared/perps/visible-state.mjs';
6
+
7
+ export async function readVisiblePerpsState(
8
+ input,
9
+ observe = observeNativeUi,
10
+ ) {
11
+ const options = resolveVisibleStateOptions(input.node);
12
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
13
+ let observed;
14
+ let screen;
15
+ let visible;
16
+ let lastAssertionError;
17
+ let lastObservationError;
18
+ do {
19
+ try {
20
+ observed = await observe(
21
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
22
+ input.context,
23
+ );
24
+ lastObservationError = undefined;
25
+ } catch (error) {
26
+ if (
27
+ /requires the idb client|requires adb|Android SDK Platform Tools/iu.test(
28
+ String(error?.message ?? error),
29
+ )
30
+ ) {
31
+ throw error;
32
+ }
33
+ lastObservationError = error;
34
+ observed = undefined;
35
+ }
36
+ screen = observed?.observations?.['ui.screen'];
37
+ visible = observed?.observations?.['ui.visible'];
38
+ if (screen && visible) {
39
+ try {
40
+ return {
41
+ action: input.action,
42
+ ...normalizeVisiblePerpsState({
43
+ route: screen.route,
44
+ title: screen.title,
45
+ items: visible.items,
46
+ offscreenItems: visible.hidden_or_offscreen,
47
+ truncated: visible.truncated,
48
+ }, options, 'mobile'),
49
+ proofPath: 'native-accessibility',
50
+ };
51
+ } catch (error) {
52
+ lastAssertionError = error;
53
+ }
54
+ }
55
+ if (Date.now() < deadline) {
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ }
58
+ } while (Date.now() < deadline);
59
+ if (lastAssertionError) throw lastAssertionError;
60
+ const warning = observed?.warnings
61
+ ?.map((entry) => entry.message)
62
+ .filter(Boolean)
63
+ .join('; ');
64
+ const observationError = lastObservationError instanceof Error
65
+ ? lastObservationError.message
66
+ : lastObservationError === undefined
67
+ ? undefined
68
+ : String(lastObservationError);
69
+ const detail = warning ?? observationError;
70
+ throw new Error(
71
+ `Mobile visible Perps state is unavailable${detail ? `: ${detail}` : '.'}`,
72
+ );
73
+ }
@@ -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.');