@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.
- package/CHANGELOG.md +24 -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/commands/call.js +2 -9
- package/dist/commands/run-engine.js +7 -71
- package/dist/commands/run.js +2 -7
- package/dist/performance-observation.js +145 -32
- package/docs/CONTRIBUTING.md +8 -0
- package/docs/PERFORMANCE-CAPTURE.md +35 -6
- package/docs/RECIPES.md +13 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +3 -8
- package/library/actions/mobile/perps/perps.mjs +1 -5
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# UI smoothness capture
|
|
2
2
|
|
|
3
|
-
`app.performance_capture` attributes
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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,
|
|
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
|
|
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 }) =>
|
|
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 }) =>
|
|
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,
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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.');
|